Repository navigation
feat: detect event loop blocking in development #7672
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
FarhanAliRaza
merged 12 commits into
reflex-dev:main
from
harsh21234i:fix/7619-event-loop-blocking
Oct 30, 2026
+641
−5
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
1349397
feat: detect event loop blocking in development
harsh21234i 53b10f4
fix: address event loop monitor review feedback
harsh21234i 745b999
test: stabilize event loop blocking coverage
harsh21234i 12c4964
perf: bypass event monitor hooks when disabled
harsh21234i 746ba2a
style: format event processor test
harsh21234i 49425c2
fix: address event blocking lint feedback
harsh21234i 97f06da
fix: report full blocking duration and attribute by event task
FarhanAliRaza 191cd00
feat: show the blocking stack in event loop blocking warnings
FarhanAliRaza 3f51a4c
fix: warn while the loop is still blocked and drop stale stacks
FarhanAliRaza b0e46a1
test: document nested helpers in blocking monitor tests
FarhanAliRaza e192a31
feat: disable blocking detection with 0 and name the app line
FarhanAliRaza a1e0579
feat: report blocking in any task, not only event tasks
FarhanAliRaza File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Add development-time detection for event handlers that block the asyncio event loop. | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Add configurable development-time warnings for event handlers that block the asyncio event loop. |
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
186 changes: 186 additions & 0 deletions
186
packages/reflex-base/src/reflex_base/event/processor/blocking.py
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| """Development-time detection of tasks blocking the asyncio loop.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import logging | ||
| import os | ||
| import sys | ||
| import sysconfig | ||
| import threading | ||
| import time | ||
| import traceback | ||
| from collections.abc import Mapping | ||
| from pathlib import Path | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| if TYPE_CHECKING: | ||
| from reflex_base.registry import RegisteredEventHandler | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| # Name prefix of the asyncio task that processes one event. | ||
| EVENT_TASK_PREFIX = "reflex_event|" | ||
|
|
||
| # The standard library and installed packages hold no app code. | ||
| _LIBRARY_PATHS = tuple({ | ||
| f"{Path(sysconfig.get_path(name))}{os.sep}" | ||
| for name in ("stdlib", "platstdlib", "purelib", "platlib") | ||
| }) | ||
|
|
||
|
|
||
| class EventLoopBlockingMonitor: | ||
| """Report tasks, such as event handlers, that keep the event loop from responding.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| loop: asyncio.AbstractEventLoop, | ||
| threshold: float, | ||
| event_handlers: Mapping[str, RegisteredEventHandler], | ||
| ) -> None: | ||
| """Create a monitor for an event loop. | ||
|
|
||
| Args: | ||
| loop: The event loop to monitor. | ||
| threshold: Blocking duration in seconds at which to warn. | ||
| event_handlers: The registered handlers, by event name. | ||
| """ | ||
| self.loop = loop | ||
| self.threshold = threshold | ||
| self.event_handlers = event_handlers | ||
| # The monitor is created on the loop thread, in the app root. | ||
| self._loop_thread_id = threading.get_ident() | ||
| self._app_root = f"{Path.cwd()}{os.sep}" | ||
| self._stop = threading.Event() | ||
| self._thread: threading.Thread | None = None | ||
|
|
||
| def start(self) -> None: | ||
| """Start the single monitor thread for this event processor.""" | ||
| self._thread = threading.Thread( | ||
| target=self._run, | ||
| name="reflex-event-loop-monitor", | ||
| daemon=True, | ||
| ) | ||
| self._thread.start() | ||
|
|
||
| def stop(self) -> None: | ||
| """Stop the monitor thread.""" | ||
| self._stop.set() | ||
| if self._thread is not None: | ||
| self._thread.join(timeout=1) | ||
| self._thread = None | ||
|
|
||
| def _describe_blocking(self) -> tuple[str, str, str, str] | None: | ||
| """Describe the task that runs on the blocked loop. | ||
|
|
||
| Returns: | ||
| The subject (event handler or task name), the scope note for event | ||
| handlers (or an empty string), the location of the innermost app | ||
| frame (or an empty string), and the formatted stack of the task, or | ||
| None if no task runs or the task has left the loop. | ||
| """ | ||
| task = asyncio.current_task(self.loop) | ||
| if task is None or (stack := self._task_stack(task)) is None: | ||
| return None | ||
| name = task.get_name() | ||
| if name.startswith(EVENT_TASK_PREFIX): | ||
| event_name = name[len(EVENT_TASK_PREFIX) :].partition("|")[0] | ||
| registered = self.event_handlers.get(event_name) | ||
| handler_name = ( | ||
| event_name if registered is None else registered.handler.fn.__qualname__ | ||
| ) | ||
| subject = f"Event handler {handler_name}" | ||
| scope = ", including state updates such as computed vars" | ||
| else: | ||
| subject, scope = f"Task {name!r}", "" | ||
| app_frame = self._app_frame(stack) | ||
| location = ( | ||
| "" | ||
| if app_frame is None | ||
| else f" at {app_frame.filename[len(self._app_root) :]}:" | ||
| f"{app_frame.lineno} in {app_frame.name}" | ||
| ) | ||
| return subject, scope, location, "".join(traceback.format_list(stack)) | ||
|
|
||
| def _app_frame( | ||
| self, stack: traceback.StackSummary | ||
| ) -> traceback.FrameSummary | None: | ||
| """Find the innermost frame of app code, outside the stdlib and packages. | ||
|
|
||
| Args: | ||
| stack: The frames, outermost first. | ||
|
|
||
| Returns: | ||
| The innermost app frame, or None if no frame is app code. | ||
| """ | ||
| for frame in reversed(stack): | ||
| if frame.filename.startswith( | ||
| self._app_root | ||
| ) and not frame.filename.startswith(_LIBRARY_PATHS): | ||
| return frame | ||
| return None | ||
|
|
||
| def _task_stack(self, task: asyncio.Task) -> traceback.StackSummary | None: | ||
| """Extract the loop thread's frames from the task entry to the blocking line. | ||
|
|
||
| Args: | ||
| task: The task that runs on the blocked loop. | ||
|
|
||
| Returns: | ||
| The frames, outermost first, or None if the loop thread no longer | ||
| runs the task. | ||
| """ | ||
| entry = getattr(task.get_coro(), "cr_frame", None) | ||
| frame = sys._current_frames().get(self._loop_thread_id) | ||
| frames = [] | ||
| while frame is not None: | ||
| frames.append(frame) | ||
| if frame is entry: | ||
| return traceback.StackSummary.extract( | ||
| (frame, frame.f_lineno) for frame in reversed(frames) | ||
| ) | ||
| frame = frame.f_back | ||
| return None | ||
|
|
||
| def _run(self) -> None: | ||
| """Poll loop responsiveness from outside the event-loop thread.""" | ||
| # Poll at one tenth of the threshold, between one and 10 milliseconds. | ||
| interval = min(max(self.threshold / 10, 0.001), 0.01) | ||
| while not self._stop.wait(interval): | ||
| responsive = threading.Event() | ||
| started = time.perf_counter() | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
|
||
| try: | ||
| self.loop.call_soon_threadsafe(responsive.set) | ||
| except RuntimeError: | ||
| return | ||
|
|
||
| # The task is only visible while the loop is blocked, so report it | ||
| # then, and report the total duration once the loop responds. | ||
| subject = None | ||
| while not responsive.wait(interval): | ||
| if self._stop.is_set(): | ||
| return | ||
| elapsed = time.perf_counter() - started | ||
| if ( | ||
| subject is None | ||
| and elapsed + interval >= self.threshold | ||
| and (blocking := self._describe_blocking()) is not None | ||
| ): | ||
| subject, scope, location, stack = blocking | ||
| logger.warning( | ||
| "%s is blocking the event loop%s (threshold: %.3fs)%s. " | ||
| "Move blocking I/O to a thread pool or use a non-blocking " | ||
| "async API.\n" | ||
| "Blocking stack (most recent call last):\n%s", | ||
| subject, | ||
| location, | ||
| self.threshold, | ||
| scope, | ||
| stack.rstrip(), | ||
| ) | ||
| if subject is not None: | ||
| logger.warning( | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
|
||
| "%s blocked the event loop for %.3fs in total.", | ||
| subject, | ||
| time.perf_counter() - started, | ||
| ) | ||
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
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
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
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.