Patch http.client to not do I/O in the event loop (#34194)

This commit is contained in:
Paulus Schoutsen 2020-04-15 15:32:10 -07:00 committed by GitHub
parent 5bfc1f3d4d
commit d011b46985
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 163 additions and 3 deletions

View file

@ -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)

View file

@ -36,7 +36,7 @@ from async_timeout import timeout
import attr import attr
import voluptuous as vol import voluptuous as vol
from homeassistant import loader, util from homeassistant import block_async_io, loader, util
from homeassistant.const import ( from homeassistant.const import (
ATTR_DOMAIN, ATTR_DOMAIN,
ATTR_FRIENDLY_NAME, ATTR_FRIENDLY_NAME,
@ -77,6 +77,9 @@ if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntries from homeassistant.config_entries import ConfigEntries
from homeassistant.components.http import HomeAssistantHTTP from homeassistant.components.http import HomeAssistantHTTP
block_async_io.enable()
# pylint: disable=invalid-name # pylint: disable=invalid-name
T = TypeVar("T") T = TypeVar("T")
CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable)

View file

@ -1,9 +1,11 @@
"""Asyncio backports for Python 3.6 compatibility.""" """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 from asyncio.events import AbstractEventLoop
import concurrent.futures import concurrent.futures
import functools
import logging import logging
import threading import threading
from traceback import extract_stack
from typing import Any, Callable, Coroutine from typing import Any, Callable, Coroutine
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -55,3 +57,65 @@ def run_callback_threadsafe(
loop.call_soon_threadsafe(run_callback) loop.call_soon_threadsafe(run_callback)
return future 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

View file

@ -1,7 +1,7 @@
"""Tests for async util methods from Python source.""" """Tests for async util methods from Python source."""
import asyncio import asyncio
from unittest import TestCase from unittest import TestCase
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, Mock, patch
import pytest import pytest
@ -165,3 +165,82 @@ class RunThreadsafeTests(TestCase):
with self.assertRaises(ValueError) as exc_context: with self.assertRaises(ValueError) as exc_context:
self.loop.run_until_complete(future) self.loop.run_until_complete(future)
self.assertIn("Invalid!", exc_context.exception.args) 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]