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

View file

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

View file

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