Skip to content

Give reflex run one owner for shutdown - #7650

Open
FarhanAliRaza wants to merge 5 commits into
mainfrom
shutdown-owner
Open

FarhanAliRaza wants to merge 5 commits into
mainfrom
shutdown-owner

Conversation

@FarhanAliRaza

@FarhanAliRaza FarhanAliRaza commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

All Submissions:

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 run survives 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

View guided diff Turn on auto-fix

`reflex run` had no single place that stops its process tree. Each layer
reacted to whatever signal reached it and relied on the terminal's process
group, Python's KeyboardInterrupt, or exit-code conventions to reach the
others. Every delivery route that differed from a terminal Ctrl-C became
its own bug: a pid-targeted SIGTERM left `npm run`'s node server behind
and hung the CLI on its pipe (#7485), the JSON supervisor's drain cap
started only after the child exited so a stalled consumer blocked exit
forever and a slow one got a cut record (#7510), and a second Ctrl-C
could interrupt the cleanup the first one started.

The shutdown now converges on one path at each layer:

- `reflex_base.utils.processes.stop_process_tree` signals a process and
  every descendant it can find (/proc on Linux, ps elsewhere, taskkill /T
  on Windows), waits for all of them against one deadline and kills what
  is left. Processes stay in the CLI's process group, so a terminal
  Ctrl-C, Ctrl-Z or a group kill still reach them directly.
- `_run_dev` installs idempotent SIGINT/SIGTERM handlers around the run:
  the first signal unwinds into cleanup, repeats are ignored. Its cleanup
  stops the whole frontend tree with that helper, on every platform, and
  `stream_logs` no longer reports an exit the CLI asked for as a failure.
- `supervise_output` forwards every SIGINT/SIGTERM to the command, arms a
  15 s deadline from the first one after which the command tree is
  killed, anchors the 30 s drain wall at the request, and writes record
  by record while stopping so the drain ends between two complete records.

Regression coverage: unit tests for the tree helper, the no-TTY run with
a frontend that does not forward signals, the supervisor with a stalled
consumer during chatty cleanup, a repeated signal, a command that ignores
shutdown, and a slow consumer at the drain cut; plus an integration matrix
that stops a real `reflex run` by group SIGINT, pid SIGINT and pid SIGTERM
in plain, --json and npm configurations and checks exit time, survivors
and error output.

Closes #7485
Closes #7510

Claude-Session: https://claude.ai/code/session_012Ps5W65XWRr7LRi3c4DvQU
@FarhanAliRaza
FarhanAliRaza requested a review from a team as a code owner October 23, 2026 14:17
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-23T14:22:27.699279Z d5c45d8 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@FarhanAliRaza FarhanAliRaza added the on deck PRs lined up to review / merge next label Oct 23, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d5c45d8eba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return
# One write per batch while the command runs; one per record once it
# is stopping, so the drain can end between two complete records.
records = data.splitlines(keepends=True) if self.stopping.is_set() else (data,)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid cutting a batch that started before shutdown

If shutdown begins while a pre-shutdown batch is blocked in _write_all, this one-time check leaves that entire multi-record batch unsplit. When the wall deadline and two-second grace expire, the daemon thread can still be partway through the batch when the supervisor exits, so a slow or paused JSON consumer receives a truncated final record—the exact case the new bounded-drain behavior is intended to prevent.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8772fbb (pushing after the local matrix passes). Records are now grouped into writes of at most PIPE_BUF bytes at every stage, not only after a stop request. A pipe accepts such a write whole or not at all, so an abandoned reader thread cannot leave a partial record; the per-record mode and the stopping event go away. Write counts and throughput for a chatty child match main. Only a single record larger than PIPE_BUF can still be cut, which is the limit of not truncating.


Generated by Claude Code

Comment on lines +156 to +157
if process.returncode is None:
descendants = [*descendants, *descendant_pids(process.pid)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Track descendants created by the shutdown handler

When the root process handles SIGTERM by spawning a cleanup subprocess and then exiting, that subprocess is absent from the initial snapshot and is reparented before _wait returns. Because the second scan only runs while process.returncode is still None, the new subprocess is never signaled and survives stop_process_tree, violating the whole-tree shutdown guarantee; descendants need to be tracked while the root-parent relationship still exists.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. The gap is real but cannot be closed by scanning: a child forked from a SIGTERM handler is reparented the moment the root exits, so no later scan under the root finds it, and a periodic scan during the wait only narrows the window. Closing it needs a process group or a subreaper, and #7558 ruled out moving the app out of the CLI's group. None of the trees reflex run starts (bun or npm in front of node, granian and its workers) forks while handling SIGTERM. The docstring in 8772fbb now states that descendant discovery is best effort for a process that forks during its own shutdown.


Generated by Claude Code

Comment on lines +88 to +94
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat zombie descendants as exited

On POSIX, kill(pid, 0) succeeds for zombies. If a terminated frontend grandchild is reparented to a non-reaping PID 1, as commonly happens in containers, _wait therefore consumes the full five-second graceful timeout and the additional one-second wait even though nothing remains runnable. Inspecting process state and treating zombies as gone avoids adding this delay to otherwise clean reflex run shutdowns.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8772fbb (pushing after the local matrix passes). _alive first tries waitpid(pid, WNOHANG), which reaps an orphan that was handed to this process when it is PID 1 or a subreaper, and on Linux reads the process state so a zombie counts as gone. Covered by test_alive_treats_an_unreaped_child_as_gone.


Generated by Claude Code

@greptile-apps

greptile-apps Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; the remaining import-rule finding is fixed.

Summary

This PR gives reflex run one owner for shutdown and bounds shutdown under --json.

  • reflex run stops its frontend tree when shutdown begins.
  • JSON command output shuts down on a shared deadline.

Reviews (4) · Last reviewed commit: "Keep the new test imports at module leve..." · Reviewed by Greptile

Comment thread packages/reflex-base/src/reflex_base/utils/log.py Outdated
Comment thread packages/reflex-base/src/reflex_base/utils/log.py
Comment thread packages/reflex-base/src/reflex_base/utils/processes.py
Comment thread tests/integration/test_run_shutdown.py Outdated
Comment thread tests/units/reflex_base/utils/test_processes.py Outdated

@cubic-dev-ai cubic-dev-ai Bot 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.

3 issues found across 12 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/reflex-base/src/reflex_base/utils/processes.py">

<violation number="1" location="packages/reflex-base/src/reflex_base/utils/processes.py:151">
P1: `stop_process_tree` abandons descendants as soon as the root is reaped, so a SIGTERM-responsive wrapper can leave its still-running frontend orphaned. Capture the tree before forwarding the first signal, or retain an equivalent process-group/tree snapshot for the later forced cleanup.</violation>

<violation number="2" location="packages/reflex-base/src/reflex_base/utils/processes.py:156">
P2: The docstring promises that descendants started during the wait are caught before the kill, but the re-scan runs only while the root is still alive. When the root exits during the wait, children it already spawned can start new processes that are never signalled. Either narrow the docstring or snapshot the tree again before the root exits.</violation>
</file>

<file name="tests/units/reflex_base/utils/test_processes.py">

<violation number="1" location="tests/units/reflex_base/utils/test_processes.py:105">
P3: This `[:1] == [proc.pid]` check depends on the arbitrary order of direct children and on no other child of pytest existing. Assert membership instead: `assert proc.pid in processes.descendant_pids(os.getpid())`.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

with contextlib.suppress(subprocess.TimeoutExpired):
process.wait(timeout=timeout)
return
if process.poll() is not None:

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.

P1: stop_process_tree abandons descendants as soon as the root is reaped, so a SIGTERM-responsive wrapper can leave its still-running frontend orphaned. Capture the tree before forwarding the first signal, or retain an equivalent process-group/tree snapshot for the later forced cleanup.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/reflex-base/src/reflex_base/utils/processes.py, line 151:

<comment>`stop_process_tree` abandons descendants as soon as the root is reaped, so a SIGTERM-responsive wrapper can leave its still-running frontend orphaned. Capture the tree before forwarding the first signal, or retain an equivalent process-group/tree snapshot for the later forced cleanup.</comment>

<file context>
@@ -0,0 +1,163 @@
+        with contextlib.suppress(subprocess.TimeoutExpired):
+            process.wait(timeout=timeout)
+        return
+    if process.poll() is not None:
+        return
+    descendants = descendant_pids(process.pid)
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not match the code. The tree is captured before the first signal (descendants = descendant_pids(process.pid) runs before _signal), and _wait returns early only when the root has exited and no captured descendant is alive; otherwise it runs to the deadline and the survivors get SIGKILL. A wrapper that exits on SIGTERM and leaves its server running is exactly test_stop_process_tree_stops_an_orphaned_grandchild and the orphaning cases of test_no_tty_run_stops_frontend, which pass.


Generated by Claude Code

Comment thread packages/reflex-base/src/reflex_base/utils/log.py Outdated
Comment thread packages/reflex-base/src/reflex_base/utils/processes.py
descendants = descendant_pids(process.pid)
_signal([process.pid, *descendants], signal.SIGTERM)
_wait(process, descendants, time.monotonic() + timeout)
if process.returncode is None:

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.

P2: The docstring promises that descendants started during the wait are caught before the kill, but the re-scan runs only while the root is still alive. When the root exits during the wait, children it already spawned can start new processes that are never signalled. Either narrow the docstring or snapshot the tree again before the root exits.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/reflex-base/src/reflex_base/utils/processes.py, line 156:

<comment>The docstring promises that descendants started during the wait are caught before the kill, but the re-scan runs only while the root is still alive. When the root exits during the wait, children it already spawned can start new processes that are never signalled. Either narrow the docstring or snapshot the tree again before the root exits.</comment>

<file context>
@@ -0,0 +1,163 @@
+    descendants = descendant_pids(process.pid)
+    _signal([process.pid, *descendants], signal.SIGTERM)
+    _wait(process, descendants, time.monotonic() + timeout)
+    if process.returncode is None:
+        descendants = [*descendants, *descendant_pids(process.pid)]
+        _signal([process.pid], signal.SIGKILL)
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8772fbb. The docstring now says the second look only helps while the root still runs, and that a process forking during its own shutdown is the documented limit of descendant discovery without a process group.


Generated by Claude Code

Comment thread reflex/reflex.py Outdated
Comment thread reflex/reflex.py Outdated
if sys.platform == "win32":
pytest.skip("taskkill walks the tree on Windows")
assert processes.descendant_pids(proc.pid) == [grandchild]
assert processes.descendant_pids(os.getpid())[:1] == [proc.pid]

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.

P3: This [:1] == [proc.pid] check depends on the arbitrary order of direct children and on no other child of pytest existing. Assert membership instead: assert proc.pid in processes.descendant_pids(os.getpid()).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At tests/units/reflex_base/utils/test_processes.py, line 105:

<comment>This `[:1] == [proc.pid]` check depends on the arbitrary order of direct children and on no other child of pytest existing. Assert membership instead: `assert proc.pid in processes.descendant_pids(os.getpid())`.</comment>

<file context>
@@ -0,0 +1,153 @@
+        if sys.platform == "win32":
+            pytest.skip("taskkill walks the tree on Windows")
+        assert processes.descendant_pids(proc.pid) == [grandchild]
+        assert processes.descendant_pids(os.getpid())[:1] == [proc.pid]
+        assert grandchild in processes.descendant_pids(os.getpid())
+    finally:
</file context>
Suggested change
assert processes.descendant_pids(os.getpid())[:1] == [proc.pid]
assert proc.pid in processes.descendant_pids(os.getpid())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6d3167702. The test checks that the child comes before its grandchild in the listing, without assuming it is the first child of pytest.


Generated by Claude Code

@codspeed

codspeed Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 4.63%

1 warning

⚠️ 22 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

❌ 1 regressed benchmark
✅ 147 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ test_on_event_router_data 1.9 ms 2 ms -4.63%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing shutdown-owner (26e5b12) with main (114312a)

Open in CodSpeed

Review findings on #7650:

- A batch blocked in the reader thread when shutdown started was still
  one write, so an abandoned write could cut a record. Records are now
  grouped into writes of at most PIPE_BUF bytes at every stage, which a
  pipe accepts whole or not at all; the per-record mode after a stop
  request goes away with it. Write counts and throughput for a chatty
  child match main.
- A descendant that exited but was never reaped, as happens for orphans
  under a non-reaping PID 1, counted as alive and cost the whole stop
  timeout. _alive now reaps orphans handed to this process and reads the
  Linux process state, so a zombie counts as gone.

Claude-Session: https://claude.ai/code/session_012Ps5W65XWRr7LRi3c4DvQU
Review findings on #7650 and the Windows unit-test failure:

- The JSON supervisor forwarded SIGTERM on Windows as TerminateProcess on
  the command alone, leaving its workers behind; it now stops the whole
  tree with taskkill at once, since Windows has no graceful signal.
- The run's SIGINT/SIGTERM guard is installed on every platform, and the
  run marks the start of its cleanup so a first signal arriving during
  the frontend stop is ignored instead of aborting it.
- Test helpers no longer race a process that exits between the liveness
  probe and the /proc read, the tree test checks ordering by membership,
  and the Windows cleanup uses SIGTERM, which exists there.

Claude-Session: https://claude.ai/code/session_012Ps5W65XWRr7LRi3c4DvQU
Comment thread packages/reflex-base/src/reflex_base/utils/log.py Outdated
Comment thread tests/units/reflex_base/utils/test_log.py Outdated

@cubic-dev-ai cubic-dev-ai Bot 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.

All reported issues were addressed across 4 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/reflex-base/src/reflex_base/utils/log.py Outdated
select.PIPE_BUF exists only on POSIX; reading it on Windows raised in the
reader thread as soon as a batch held two records. The size is now a
module constant with the Linux value as fallback, the test reads that
constant, and the supervisor docstring scopes the record-boundary
guarantee to records that fit one write.

Claude-Session: https://claude.ai/code/session_012Ps5W65XWRr7LRi3c4DvQU
Comment thread tests/units/reflex_base/utils/test_log.py Outdated
The slow-consumer drain test no longer shrinks the pipe with fcntl: the
default pipe already holds far less than the test writes, so the cut
happens regardless, and the test now runs on every POSIX platform. psutil
is imported at the top of the tree test module like elsewhere in tests.

Claude-Session: https://claude.ai/code/session_012Ps5W65XWRr7LRi3c4DvQU

Copy link
Copy Markdown
Contributor Author

The red CodSpeed Performance Analysis check on 26e5b12 is measurement noise, not this PR's. It reports a 4.6% slowdown of test_on_event_router_data, a benchmark of event processing that none of the changed modules take part in. The commit it flags changed only two test files relative to 11d5f31, which CodSpeed measured as "will not alter performance", as it did for every earlier head of this branch. CodSpeed is not among the required checks, so I am not re-running it; a maintainer can acknowledge it on CodSpeed if the red mark is in the way.


Generated by Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

on deck PRs lined up to review / merge next

Projects

None yet

1 participant