From 80f7f0734144eaec1b35dc2a476dcf8130e04117 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 25 Mar 2025 04:40:43 -0400 Subject: [PATCH] Add blockbuster option to API (#5746) * Add blockbuster option to API * cache not lru_cache --- supervisor/__main__.py | 12 ++++++--- supervisor/api/const.py | 8 ++++++ supervisor/api/supervisor.py | 21 ++++++++++++++- supervisor/config.py | 11 ++++++++ supervisor/const.py | 1 + supervisor/utils/blockbuster.py | 35 +++++++++++++++++++++++++ supervisor/validate.py | 2 ++ tests/api/test_supervisor.py | 45 +++++++++++++++++++++++++++++++++ tests/conftest.py | 6 ++++- 9 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 supervisor/utils/blockbuster.py diff --git a/supervisor/__main__.py b/supervisor/__main__.py index f5c438e1a..17dc292e3 100644 --- a/supervisor/__main__.py +++ b/supervisor/__main__.py @@ -11,10 +11,12 @@ import zlib_fast # Enable fast zlib before importing supervisor zlib_fast.enable() -from supervisor import bootstrap # pylint: disable=wrong-import-position # noqa: E402 -from supervisor.utils.logging import ( # pylint: disable=wrong-import-position # noqa: E402 - activate_log_queue_handler, -) +# pylint: disable=wrong-import-position +from supervisor import bootstrap # noqa: E402 +from supervisor.utils.blockbuster import activate_blockbuster # noqa: E402 +from supervisor.utils.logging import activate_log_queue_handler # noqa: E402 + +# pylint: enable=wrong-import-position _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -52,6 +54,8 @@ if __name__ == "__main__": _LOGGER.info("Initializing Supervisor setup") coresys = loop.run_until_complete(bootstrap.initialize_coresys()) loop.set_debug(coresys.config.debug) + if coresys.config.detect_blocking_io: + activate_blockbuster() loop.run_until_complete(coresys.core.connect()) loop.run_until_complete(bootstrap.supervisor_debugger(coresys)) diff --git a/supervisor/api/const.py b/supervisor/api/const.py index 75d939680..a49b88f94 100644 --- a/supervisor/api/const.py +++ b/supervisor/api/const.py @@ -80,3 +80,11 @@ class BootSlot(StrEnum): A = "A" B = "B" + + +class DetectBlockingIO(StrEnum): + """Enable/Disable detection for blocking I/O in event loop.""" + + OFF = "off" + ON = "on" + ON_AT_STARTUP = "on_at_startup" diff --git a/supervisor/api/supervisor.py b/supervisor/api/supervisor.py index 8b012a0f2..e95f05d51 100644 --- a/supervisor/api/supervisor.py +++ b/supervisor/api/supervisor.py @@ -20,6 +20,7 @@ from ..const import ( ATTR_CPU_PERCENT, ATTR_DEBUG, ATTR_DEBUG_BLOCK, + ATTR_DETECT_BLOCKING_IO, ATTR_DIAGNOSTICS, ATTR_FORCE_SECURITY, ATTR_HEALTHY, @@ -47,10 +48,15 @@ from ..const import ( from ..coresys import CoreSysAttributes from ..exceptions import APIError from ..store.validate import repositories +from ..utils.blockbuster import ( + activate_blockbuster, + blockbuster_enabled, + deactivate_blockbuster, +) from ..utils.sentry import close_sentry, init_sentry from ..utils.validate import validate_timezone from ..validate import version_tag, wait_boot -from .const import CONTENT_TYPE_TEXT +from .const import CONTENT_TYPE_TEXT, DetectBlockingIO from .utils import api_process, api_process_raw, api_validate _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -69,6 +75,7 @@ SCHEMA_OPTIONS = vol.Schema( vol.Optional(ATTR_CONTENT_TRUST): vol.Boolean(), vol.Optional(ATTR_FORCE_SECURITY): vol.Boolean(), vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(), + vol.Optional(ATTR_DETECT_BLOCKING_IO): vol.Coerce(DetectBlockingIO), } ) @@ -101,6 +108,7 @@ class APISupervisor(CoreSysAttributes): ATTR_DEBUG_BLOCK: self.sys_config.debug_block, ATTR_DIAGNOSTICS: self.sys_config.diagnostics, ATTR_AUTO_UPDATE: self.sys_updater.auto_update, + ATTR_DETECT_BLOCKING_IO: blockbuster_enabled(), # Depricated ATTR_WAIT_BOOT: self.sys_config.wait_boot, ATTR_ADDONS: [ @@ -160,6 +168,17 @@ class APISupervisor(CoreSysAttributes): if ATTR_AUTO_UPDATE in body: self.sys_updater.auto_update = body[ATTR_AUTO_UPDATE] + if detect_blocking_io := body.get(ATTR_DETECT_BLOCKING_IO): + if detect_blocking_io == DetectBlockingIO.ON_AT_STARTUP: + self.sys_config.detect_blocking_io = True + detect_blocking_io = DetectBlockingIO.ON + + if detect_blocking_io == DetectBlockingIO.ON: + activate_blockbuster() + elif detect_blocking_io == DetectBlockingIO.OFF: + self.sys_config.detect_blocking_io = False + deactivate_blockbuster() + # Deprecated if ATTR_WAIT_BOOT in body: self.sys_config.wait_boot = body[ATTR_WAIT_BOOT] diff --git a/supervisor/config.py b/supervisor/config.py index 6d6d064cc..ac6ee1a4b 100644 --- a/supervisor/config.py +++ b/supervisor/config.py @@ -12,6 +12,7 @@ from .const import ( ATTR_ADDONS_CUSTOM_LIST, ATTR_DEBUG, ATTR_DEBUG_BLOCK, + ATTR_DETECT_BLOCKING_IO, ATTR_DIAGNOSTICS, ATTR_IMAGE, ATTR_LAST_BOOT, @@ -142,6 +143,16 @@ class CoreConfig(FileConfiguration): """Set debug wait mode.""" self._data[ATTR_DEBUG_BLOCK] = value + @property + def detect_blocking_io(self) -> bool: + """Return True if blocking I/O in event loop detection enabled at startup.""" + return self._data[ATTR_DETECT_BLOCKING_IO] + + @detect_blocking_io.setter + def detect_blocking_io(self, value: bool) -> None: + """Enable/Disable blocking I/O in event loop detection at startup.""" + self._data[ATTR_DETECT_BLOCKING_IO] = value + @property def diagnostics(self) -> bool | None: """Return bool if diagnostics is set otherwise None.""" diff --git a/supervisor/const.py b/supervisor/const.py index 4bfde2906..dded9c751 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -152,6 +152,7 @@ ATTR_DEFAULT = "default" ATTR_DEPLOYMENT = "deployment" ATTR_DESCRIPTON = "description" ATTR_DETACHED = "detached" +ATTR_DETECT_BLOCKING_IO = "detect_blocking_io" ATTR_DEVICES = "devices" ATTR_DEVICETREE = "devicetree" ATTR_DIAGNOSTICS = "diagnostics" diff --git a/supervisor/utils/blockbuster.py b/supervisor/utils/blockbuster.py new file mode 100644 index 000000000..2657c3176 --- /dev/null +++ b/supervisor/utils/blockbuster.py @@ -0,0 +1,35 @@ +"""Activate and deactivate blockbuster for finding blocking I/O.""" + +from functools import cache +import logging + +from blockbuster import BlockBuster + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +@cache +def _get_blockbuster() -> BlockBuster: + """Get blockbuster instance.""" + return BlockBuster() + + +def blockbuster_enabled() -> bool: + """Return true if blockbuster detection is enabled.""" + blockbuster = _get_blockbuster() + # We activate all or none so just check the first one + for _, fn in blockbuster.functions.items(): + return fn.activated + return False + + +def activate_blockbuster() -> None: + """Activate blockbuster detection.""" + _LOGGER.info("Activating BlockBuster blocking I/O detection") + _get_blockbuster().activate() + + +def deactivate_blockbuster() -> None: + """Deactivate blockbuster detection.""" + _LOGGER.info("Deactivating BlockBuster blocking I/O detection") + _get_blockbuster().deactivate() diff --git a/supervisor/validate.py b/supervisor/validate.py index b22a1b3e4..0bfcf16b7 100644 --- a/supervisor/validate.py +++ b/supervisor/validate.py @@ -15,6 +15,7 @@ from .const import ( ATTR_CONTENT_TRUST, ATTR_DEBUG, ATTR_DEBUG_BLOCK, + ATTR_DETECT_BLOCKING_IO, ATTR_DIAGNOSTICS, ATTR_DISPLAYNAME, ATTR_DNS, @@ -162,6 +163,7 @@ SCHEMA_SUPERVISOR_CONFIG = vol.Schema( vol.Optional(ATTR_DEBUG, default=False): vol.Boolean(), vol.Optional(ATTR_DEBUG_BLOCK, default=False): vol.Boolean(), vol.Optional(ATTR_DIAGNOSTICS, default=None): vol.Maybe(vol.Boolean()), + vol.Optional(ATTR_DETECT_BLOCKING_IO, default=False): vol.Boolean(), }, extra=vol.REMOVE_EXTRA, ) diff --git a/tests/api/test_supervisor.py b/tests/api/test_supervisor.py index e6ebe908a..65993b2c5 100644 --- a/tests/api/test_supervisor.py +++ b/tests/api/test_supervisor.py @@ -1,9 +1,11 @@ """Test Supervisor API.""" # pylint: disable=protected-access +import time from unittest.mock import MagicMock, patch from aiohttp.test_utils import TestClient +from blockbuster import BlockingError import pytest from supervisor.coresys import CoreSys @@ -247,3 +249,46 @@ async def test_api_supervisor_options_timezone( assert resp.status == 200 assert coresys.timezone == "Europe/Zurich" + + +@pytest.mark.parametrize( + ("blockbuster", "option_value", "config_value"), + [("no_blockbuster", "on", False), ("no_blockbuster", "on_at_startup", True)], + indirect=["blockbuster"], +) +async def test_api_supervisor_options_blocking_io( + api_client: TestClient, coresys: CoreSys, option_value: str, config_value: bool +): + """Test setting supervisor detect blocking io option.""" + # This should not fail with a blocking error yet + time.sleep(0) + + resp = await api_client.post( + "/supervisor/options", json={"detect_blocking_io": option_value} + ) + assert resp.status == 200 + + resp = await api_client.get("/supervisor/info") + assert resp.status == 200 + body = await resp.json() + assert body["data"]["detect_blocking_io"] is True + + # This remains false because we only turned it on for current run of supervisor, not permanently + assert coresys.config.detect_blocking_io is config_value + + with pytest.raises(BlockingError): + time.sleep(0) + + resp = await api_client.post( + "/supervisor/options", json={"detect_blocking_io": "off"} + ) + assert resp.status == 200 + + resp = await api_client.get("/supervisor/info") + assert resp.status == 200 + body = await resp.json() + assert body["data"]["detect_blocking_io"] is False + assert coresys.config.detect_blocking_io is False + + # This should not raise blocking error anymore + time.sleep(0) diff --git a/tests/conftest.py b/tests/conftest.py index 8e87e4baa..d90739aa5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -65,8 +65,12 @@ from .dbus_service_mocks.network_manager import NetworkManager as NetworkManager @pytest.fixture(autouse=True) -def blockbuster() -> BlockBuster: +def blockbuster(request: pytest.FixtureRequest) -> BlockBuster | None: """Raise for blocking I/O in event loop.""" + if getattr(request, "param", "") == "no_blockbuster": + yield None + return + # Only scanning supervisor code for now as that's our primary interest # This will still raise for tests that call utilities in supervisor code that block # But it will ignore calls to libraries and such that do blocking I/O directly from tests