From d011b469852c05c63f26c14c543f3e26f29402e1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 15 Apr 2020 15:32:10 -0700 Subject: [PATCH] Patch http.client to not do I/O in the event loop (#34194) --- homeassistant/block_async_io.py | 14 ++++++ homeassistant/core.py | 5 +- homeassistant/util/async_.py | 66 ++++++++++++++++++++++++++- tests/util/test_async.py | 81 ++++++++++++++++++++++++++++++++- 4 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 homeassistant/block_async_io.py diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py new file mode 100644 index 00000000000..cd33a4207a8 --- /dev/null +++ b/homeassistant/block_async_io.py @@ -0,0 +1,14 @@ +"""Block I/O being done in asyncio.""" +from http.client import HTTPConnection + +from homeassistant.util.async_ import protect_loop + + +def enable() -> None: + """Enable the detection of I/O in the event loop.""" + # Prevent urllib3 and requests doing I/O in event loop + HTTPConnection.putrequest = protect_loop(HTTPConnection.putrequest) + + # Currently disabled. pytz doing I/O when getting timezone. + # Prevent files being opened inside the event loop + # builtins.open = protect_loop(builtins.open) diff --git a/homeassistant/core.py b/homeassistant/core.py index d35e7f48cc7..2553dbea74e 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -36,7 +36,7 @@ from async_timeout import timeout import attr import voluptuous as vol -from homeassistant import loader, util +from homeassistant import block_async_io, loader, util from homeassistant.const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, @@ -77,6 +77,9 @@ if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntries from homeassistant.components.http import HomeAssistantHTTP + +block_async_io.enable() + # pylint: disable=invalid-name T = TypeVar("T") CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index 212c2bff910..5785759d591 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -1,9 +1,11 @@ """Asyncio backports for Python 3.6 compatibility.""" -from asyncio import coroutines, ensure_future +from asyncio import coroutines, ensure_future, get_running_loop from asyncio.events import AbstractEventLoop import concurrent.futures +import functools import logging import threading +from traceback import extract_stack from typing import Any, Callable, Coroutine _LOGGER = logging.getLogger(__name__) @@ -55,3 +57,65 @@ def run_callback_threadsafe( loop.call_soon_threadsafe(run_callback) return future + + +def check_loop() -> None: + """Warn if called inside the event loop.""" + try: + get_running_loop() + in_loop = True + except RuntimeError: + in_loop = False + + if not in_loop: + return + + found_frame = None + + for frame in reversed(extract_stack()): + for path in ("custom_components/", "homeassistant/components/"): + try: + index = frame.filename.index(path) + found_frame = frame + break + except ValueError: + continue + + if found_frame is not None: + break + + # Did not source from integration? Hard error. + if found_frame is None: + raise RuntimeError( + "Detected I/O inside the event loop. This is causing stability issues. Please report issue" + ) + + start = index + len(path) + end = found_frame.filename.index("/", start) + + integration = found_frame.filename[start:end] + + if path == "custom_components/": + extra = " to the custom component author" + else: + extra = "" + + _LOGGER.warning( + "Detected I/O inside the event loop. This is causing stability issues. Please report issue%s for %s doing I/O at %s, line %s: %s", + extra, + integration, + found_frame.filename[index:], + found_frame.lineno, + found_frame.line.strip(), + ) + + +def protect_loop(func: Callable) -> Callable: + """Protect function from running in event loop.""" + + @functools.wraps(func) + def protected_loop_func(*args, **kwargs): # type: ignore + check_loop() + return func(*args, **kwargs) + + return protected_loop_func diff --git a/tests/util/test_async.py b/tests/util/test_async.py index 098b04a3048..33280895ba2 100644 --- a/tests/util/test_async.py +++ b/tests/util/test_async.py @@ -1,7 +1,7 @@ """Tests for async util methods from Python source.""" import asyncio from unittest import TestCase -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch import pytest @@ -165,3 +165,82 @@ class RunThreadsafeTests(TestCase): with self.assertRaises(ValueError) as exc_context: self.loop.run_until_complete(future) self.assertIn("Invalid!", exc_context.exception.args) + + +async def test_check_loop_async(): + """Test check_loop detects when called from event loop without integration context.""" + with pytest.raises(RuntimeError): + hasync.check_loop() + + +async def test_check_loop_async_integration(caplog): + """Test check_loop detects when called from event loop from integration context.""" + with patch( + "homeassistant.util.async_.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="self.light.is_on", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + hasync.check_loop() + assert ( + "Detected I/O inside the event loop. This is causing stability issues. Please report issue for hue doing I/O at homeassistant/components/hue/light.py, line 23: self.light.is_on" + in caplog.text + ) + + +async def test_check_loop_async_custom(caplog): + """Test check_loop detects when called from event loop with custom component context.""" + with patch( + "homeassistant.util.async_.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename="/home/paulus/config/custom_components/hue/light.py", + lineno="23", + line="self.light.is_on", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + hasync.check_loop() + assert ( + "Detected I/O inside the event loop. This is causing stability issues. Please report issue to the custom component author for hue doing I/O at custom_components/hue/light.py, line 23: self.light.is_on" + in caplog.text + ) + + +def test_check_loop_sync(caplog): + """Test check_loop does nothing when called from thread.""" + hasync.check_loop() + assert "Detected I/O inside the event loop" not in caplog.text + + +def test_protect_loop_sync(): + """Test protect_loop calls check_loop.""" + calls = [] + with patch("homeassistant.util.async_.check_loop") as mock_loop: + hasync.protect_loop(calls.append)(1) + assert len(mock_loop.mock_calls) == 1 + assert calls == [1]