Give reflex run one owner for shutdown
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? (#7578 covers one of the cases; this PR supersedes it.)
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?
Why
reflex run has no single place that stops its process tree. Each layer reacts to whatever signal happens to reach it and relies on the terminal's process group, Python's KeyboardInterrupt, or exit-code conventions to reach the others. Every delivery route that differs from a terminal Ctrl-C has become its own issue and its own PR: #6980 / #7328 (no TTY), #7509 / #7558 (pid-targeted SIGINT under --json), #7485 (npm leaves node behind and the CLI hangs on its pipe), #7510 / #7428 / #7578 (the JSON drain cap), #6981 / #7266 / #7457 / #7538 (spurious errors on a clean stop).
This PR gives shutdown one owner per layer so that the route a stop takes no longer matters. The shape follows what uvicorn, gunicorn, honcho, supervisord, concurrently and tini do: the handler only records the request, the whole set is waited on against one shared deadline, stragglers get SIGKILL, and the parent remembers which exits it asked for.
What changes
reflex_base.utils.processes.stop_process_tree (new) signals a process and every descendant it can find, waits for all of them against one deadline, then kills whatever is left. Descendants come from /proc on Linux, ps on other POSIX systems and taskkill /T /F on Windows, so no new dependency. Processes stay in the CLI's process group on purpose: a terminal Ctrl-C, Ctrl-Z, supervisord killasgroup or kill -9 -<pgid> still reach every process directly, which is the decision made in #7558.
reflex run (_run_dev) installs idempotent SIGINT/SIGTERM handlers around the run. The first signal unwinds into cleanup; repeats, such as the second delivery a terminal produces next to the one the JSON supervisor forwards, are ignored instead of interrupting that cleanup. The cleanup stops the whole frontend tree with the helper on every platform, which replaces the direct-child terminate() and the Windows-only psutil kill. stream_logs takes expected_exit so an exit the CLI asked for is never reported as Starting frontend failed with exit code ....
reflex run --json (supervise_output) forwards every SIGINT/SIGTERM to the command; a threading.Timer armed by the first one kills the command tree after 15 s, so a child blocked on a full output pipe cannot keep the supervisor alive. The 30 s drain wall is anchored at the request instead of at the child's exit. While stopping, readers write record by record and a record in flight when the wall is hit gets a short grace, so a consumer that still reads never receives a cut record. Normal operation keeps one write per batch, and records are never truncated.
Evidence
Run against main's source on Linux, these cells of the new matrix fail: with REFLEX_USE_NPM=1 a pid-targeted SIGTERM leaves node react-router dev running and reflex run never exits (the frontend thread is blocked on the pipe node holds); a pid SIGINT with bun hung the same way in this environment. With this branch all nine cells pass in about 70 s after the one-time init:
tests/integration/test_run_shutdown.py: {group SIGINT, pid SIGINT, pid SIGTERM} x {plain, --json, plain+npm}
asserts: exit code in (0, 130, 143), exit under 20 s, no surviving descendant, no error line / every line valid JSON
Scripted supervisor cases (unit tests, compared on main and this branch): a consumer that stops reading while the child prints during cleanup hangs main past 75 s and exits here after the deadline; a child that needs two interrupts gets both; a child that ignores signals is killed at the deadline; a slow consumer's stream ends at a record boundary where main cut mid-record.
Local validation: the touched unit test files pass (243 tests), the full unit suite passes except tests/units/reflex_cli/v2 and the reflex-bench browser tests, which fail identically on main's source in this environment (hosting-cli version gate and a Playwright browser mismatch). Ruff, ruff format and pyright are clean on the changed files.
Not in this PR
- SIGHUP on terminal close: granian treats SIGHUP as "reload workers", so the inner
reflex runsurvives a closed terminal on main and on this branch alike. Mapping SIGHUP to stop in dev mode is a separate decision. - Running the dev server binary directly instead of through
bun run/npm run, which would remove the intermediary altogether. Tree stop covers it without changing how the frontend is launched.
Closes #7485
Closes #7510
Supersedes #7578
https://claude.ai/code/session_012Ps5W65XWRr7LRi3c4DvQU
Generated by Claude Code