Move read_text to executor (#5688)

* Move read_text to executor

* Fix issues found by coderabbit

* formated to formatted

* switch to async_capture_exception

* Find and replace got one too many

* Update patch mock to async_capture_exception

* Drop Sentry capture from format_message

The error handling got introduced in #2052, however, #2100 essentially
makes sure there will never be a byte object passed to this function.
And even if, the Sentry aiohttp plug-in will properly catch such an
exception.

---------

Co-authored-by: Stefan Agner <stefan@agner.ch>
This commit is contained in:
Mike Degatano 2025-03-01 10:02:43 -05:00 committed by GitHub
parent 12c951f62d
commit 86133f8ecd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 213 additions and 175 deletions

View File

@ -54,7 +54,7 @@ if __name__ == "__main__":
loop.set_debug(coresys.config.debug)
loop.run_until_complete(coresys.core.connect())
bootstrap.supervisor_debugger(coresys)
loop.run_until_complete(bootstrap.supervisor_debugger(coresys))
# Signal health startup for container
run_os_startup_check_cleanup()

View File

@ -88,7 +88,7 @@ from ..store.addon import AddonStore
from ..utils import check_port
from ..utils.apparmor import adjust_profile
from ..utils.json import read_json_file, write_json_file
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .const import (
WATCHDOG_MAX_ATTEMPTS,
WATCHDOG_RETRY_SECONDS,
@ -1530,7 +1530,7 @@ class Addon(AddonModel):
except AddonsError as err:
attempts = attempts + 1
_LOGGER.error("Watchdog restart of addon %s failed!", self.name)
capture_exception(err)
await async_capture_exception(err)
else:
break

View File

@ -23,7 +23,7 @@ from ..exceptions import (
from ..jobs.decorator import Job, JobCondition
from ..resolution.const import ContextType, IssueType, SuggestionType
from ..store.addon import AddonStore
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .addon import Addon
from .const import ADDON_UPDATE_CONDITIONS
from .data import AddonsData
@ -170,7 +170,7 @@ class AddonManager(CoreSysAttributes):
await addon.stop()
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, err)
capture_exception(err)
await async_capture_exception(err)
@Job(
name="addon_manager_install",
@ -388,7 +388,7 @@ class AddonManager(CoreSysAttributes):
reference=addon.slug,
suggestions=[SuggestionType.EXECUTE_REPAIR],
)
capture_exception(err)
await async_capture_exception(err)
else:
add_host_coros.append(
self.sys_plugins.dns.add_host(

View File

@ -210,18 +210,6 @@ class AddonModel(JobGroup, ABC):
"""Return description of add-on."""
return self.data[ATTR_DESCRIPTON]
@property
def long_description(self) -> str | None:
"""Return README.md as long_description."""
readme = Path(self.path_location, "README.md")
# If readme not exists
if not readme.exists():
return None
# Return data
return readme.read_text(encoding="utf-8")
@property
def repository(self) -> str:
"""Return repository of add-on."""
@ -646,6 +634,21 @@ class AddonModel(JobGroup, ABC):
"""Return breaking versions of addon."""
return self.data[ATTR_BREAKING_VERSIONS]
async def long_description(self) -> str | None:
"""Return README.md as long_description."""
def read_readme() -> str | None:
readme = Path(self.path_location, "README.md")
# If readme not exists
if not readme.exists():
return None
# Return data
return readme.read_text(encoding="utf-8")
return await self.sys_run_in_executor(read_readme)
def refresh_path_cache(self) -> Awaitable[None]:
"""Refresh cache of existing paths."""

View File

@ -10,7 +10,7 @@ from aiohttp import web
from ..const import AddonState
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import APIAddonNotInstalled, HostNotSupportedError
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .addons import APIAddons
from .audio import APIAudio
from .auth import APIAuth
@ -412,7 +412,7 @@ class RestAPI(CoreSysAttributes):
if not isinstance(err, HostNotSupportedError):
# No need to capture HostNotSupportedError to Sentry, the cause
# is known and reported to the user using the resolution center.
capture_exception(err)
await async_capture_exception(err)
kwargs.pop("follow", None) # Follow is not supported for Docker logs
return await api_supervisor.logs(*args, **kwargs)

View File

@ -212,7 +212,7 @@ class APIAddons(CoreSysAttributes):
ATTR_HOSTNAME: addon.hostname,
ATTR_DNS: addon.dns,
ATTR_DESCRIPTON: addon.description,
ATTR_LONG_DESCRIPTION: addon.long_description,
ATTR_LONG_DESCRIPTION: await addon.long_description(),
ATTR_ADVANCED: addon.advanced,
ATTR_STAGE: addon.stage,
ATTR_REPOSITORY: addon.repository,

View File

@ -98,10 +98,10 @@ class APIHost(CoreSysAttributes):
ATTR_VIRTUALIZATION: self.sys_host.info.virtualization,
ATTR_CPE: self.sys_host.info.cpe,
ATTR_DEPLOYMENT: self.sys_host.info.deployment,
ATTR_DISK_FREE: self.sys_host.info.free_space,
ATTR_DISK_TOTAL: self.sys_host.info.total_space,
ATTR_DISK_USED: self.sys_host.info.used_space,
ATTR_DISK_LIFE_TIME: self.sys_host.info.disk_life_time,
ATTR_DISK_FREE: await self.sys_host.info.free_space(),
ATTR_DISK_TOTAL: await self.sys_host.info.total_space(),
ATTR_DISK_USED: await self.sys_host.info.used_space(),
ATTR_DISK_LIFE_TIME: await self.sys_host.info.disk_life_time(),
ATTR_FEATURES: self.sys_host.features,
ATTR_HOSTNAME: self.sys_host.info.hostname,
ATTR_LLMNR_HOSTNAME: self.sys_host.info.llmnr_hostname,

View File

@ -69,12 +69,12 @@ SCHEMA_ADD_REPOSITORY = vol.Schema(
)
def _read_static_file(path: Path) -> Any:
def _read_static_file(path: Path, binary: bool = False) -> Any:
"""Read in a static file asset for API output.
Must be run in executor.
"""
with path.open("r") as asset:
with path.open("rb" if binary else "r") as asset:
return asset.read()
@ -109,7 +109,7 @@ class APIStore(CoreSysAttributes):
return self.sys_store.get(repository_slug)
def _generate_addon_information(
async def _generate_addon_information(
self, addon: AddonStore, extended: bool = False
) -> dict[str, Any]:
"""Generate addon information."""
@ -156,7 +156,7 @@ class APIStore(CoreSysAttributes):
ATTR_HOST_NETWORK: addon.host_network,
ATTR_HOST_PID: addon.host_pid,
ATTR_INGRESS: addon.with_ingress,
ATTR_LONG_DESCRIPTION: addon.long_description,
ATTR_LONG_DESCRIPTION: await addon.long_description(),
ATTR_RATING: rating_security(addon),
ATTR_SIGNED: addon.signed,
}
@ -185,10 +185,12 @@ class APIStore(CoreSysAttributes):
async def store_info(self, request: web.Request) -> dict[str, Any]:
"""Return store information."""
return {
ATTR_ADDONS: [
self._generate_addon_information(self.sys_addons.store[addon])
for addon in self.sys_addons.store
],
ATTR_ADDONS: await asyncio.gather(
*[
self._generate_addon_information(self.sys_addons.store[addon])
for addon in self.sys_addons.store
]
),
ATTR_REPOSITORIES: [
self._generate_repository_information(repository)
for repository in self.sys_store.all
@ -199,10 +201,12 @@ class APIStore(CoreSysAttributes):
async def addons_list(self, request: web.Request) -> dict[str, Any]:
"""Return all store add-ons."""
return {
ATTR_ADDONS: [
self._generate_addon_information(self.sys_addons.store[addon])
for addon in self.sys_addons.store
]
ATTR_ADDONS: await asyncio.gather(
*[
self._generate_addon_information(self.sys_addons.store[addon])
for addon in self.sys_addons.store
]
)
}
@api_process
@ -234,7 +238,7 @@ class APIStore(CoreSysAttributes):
async def addons_addon_info_wrapped(self, request: web.Request) -> dict[str, Any]:
"""Return add-on information directly (not api)."""
addon: AddonStore = self._extract_addon(request)
return self._generate_addon_information(addon, True)
return await self._generate_addon_information(addon, True)
@api_process_raw(CONTENT_TYPE_PNG)
async def addons_addon_icon(self, request: web.Request) -> bytes:
@ -243,7 +247,7 @@ class APIStore(CoreSysAttributes):
if not addon.with_icon:
raise APIError(f"No icon found for add-on {addon.slug}!")
return await self.sys_run_in_executor(_read_static_file, addon.path_icon)
return await self.sys_run_in_executor(_read_static_file, addon.path_icon, True)
@api_process_raw(CONTENT_TYPE_PNG)
async def addons_addon_logo(self, request: web.Request) -> bytes:
@ -252,7 +256,7 @@ class APIStore(CoreSysAttributes):
if not addon.with_logo:
raise APIError(f"No logo found for add-on {addon.slug}!")
return await self.sys_run_in_executor(_read_static_file, addon.path_logo)
return await self.sys_run_in_executor(_read_static_file, addon.path_logo, True)
@api_process_raw(CONTENT_TYPE_TEXT)
async def addons_addon_changelog(self, request: web.Request) -> str:

View File

@ -36,7 +36,7 @@ from ..resolution.const import UnhealthyReason
from ..utils.common import FileConfiguration
from ..utils.dt import utcnow
from ..utils.sentinel import DEFAULT
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .backup import Backup
from .const import (
DEFAULT_FREEZE_TIMEOUT,
@ -525,7 +525,7 @@ class BackupManager(FileConfiguration, JobGroup):
return None
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception("Backup %s error", backup.slug)
capture_exception(err)
await async_capture_exception(err)
self.sys_jobs.current.capture_error(
BackupError(f"Backup {backup.slug} error, see supervisor logs")
)
@ -718,7 +718,7 @@ class BackupManager(FileConfiguration, JobGroup):
raise
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception("Restore %s error", backup.slug)
capture_exception(err)
await async_capture_exception(err)
raise BackupError(
f"Restore {backup.slug} error, see supervisor logs"
) from err

View File

@ -1,6 +1,7 @@
"""Bootstrap Supervisor."""
# ruff: noqa: T100
from importlib import import_module
import logging
import os
import signal
@ -306,12 +307,12 @@ def reg_signal(loop, coresys: CoreSys) -> None:
_LOGGER.warning("Could not bind to SIGINT")
def supervisor_debugger(coresys: CoreSys) -> None:
async def supervisor_debugger(coresys: CoreSys) -> None:
"""Start debugger if needed."""
if not coresys.config.debug:
return
# pylint: disable=import-outside-toplevel
import debugpy
debugpy = await coresys.run_in_executor(import_module, "debugpy")
_LOGGER.info("Initializing Supervisor debugger")

View File

@ -26,7 +26,7 @@ from .exceptions import (
from .homeassistant.core import LANDINGPAGE
from .resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason
from .utils.dt import utcnow
from .utils.sentry import capture_exception
from .utils.sentry import async_capture_exception
from .utils.whoami import WhoamiData, retrieve_whoami
_LOGGER: logging.Logger = logging.getLogger(__name__)
@ -172,7 +172,7 @@ class Core(CoreSysAttributes):
"Fatal error happening on load Task %s: %s", setup_task, err
)
self.sys_resolution.unhealthy = UnhealthyReason.SETUP
capture_exception(err)
await async_capture_exception(err)
# Set OS Agent diagnostics if needed
if (
@ -189,7 +189,7 @@ class Core(CoreSysAttributes):
self.sys_config.diagnostics,
err,
)
capture_exception(err)
await async_capture_exception(err)
# Evaluate the system
await self.sys_resolution.evaluate.evaluate_system()
@ -246,12 +246,12 @@ class Core(CoreSysAttributes):
await self.sys_homeassistant.core.start()
except HomeAssistantCrashError as err:
_LOGGER.error("Can't start Home Assistant Core - rebuiling")
capture_exception(err)
await async_capture_exception(err)
with suppress(HomeAssistantError):
await self.sys_homeassistant.core.rebuild()
except HomeAssistantError as err:
capture_exception(err)
await async_capture_exception(err)
else:
_LOGGER.info("Skipping start of Home Assistant")

View File

@ -15,7 +15,7 @@ from ...exceptions import (
HostNotSupportedError,
NetworkInterfaceNotFound,
)
from ...utils.sentry import capture_exception
from ...utils.sentry import async_capture_exception
from ..const import (
DBUS_ATTR_CONNECTION_ENABLED,
DBUS_ATTR_DEVICES,
@ -223,13 +223,13 @@ class NetworkManager(DBusInterfaceProxy):
device,
err,
)
capture_exception(err)
await async_capture_exception(err)
return
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception(
"Unkown error while processing %s: %s", device, err
)
capture_exception(err)
await async_capture_exception(err)
continue
# Skeep interface

View File

@ -42,7 +42,7 @@ from ..hardware.data import Device
from ..jobs.const import JobCondition, JobExecutionLimit
from ..jobs.decorator import Job
from ..resolution.const import CGROUP_V2_VERSION, ContextType, IssueType, SuggestionType
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .const import (
ENV_TIME,
ENV_TOKEN,
@ -606,7 +606,7 @@ class DockerAddon(DockerInterface):
)
except CoreDNSError as err:
_LOGGER.warning("Can't update DNS for %s", self.name)
capture_exception(err)
await async_capture_exception(err)
# Hardware Access
if self.addon.static_devices:
@ -787,7 +787,7 @@ class DockerAddon(DockerInterface):
await self.sys_plugins.dns.delete_host(self.addon.hostname)
except CoreDNSError as err:
_LOGGER.warning("Can't update DNS for %s", self.name)
capture_exception(err)
await async_capture_exception(err)
# Hardware
if self._hw_listener:

View File

@ -42,7 +42,7 @@ from ..jobs.const import JOB_GROUP_DOCKER_INTERFACE, JobExecutionLimit
from ..jobs.decorator import Job
from ..jobs.job_group import JobGroup
from ..resolution.const import ContextType, IssueType, SuggestionType
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .const import ContainerState, RestartPolicy
from .manager import CommandReturn
from .monitor import DockerContainerStateEvent
@ -278,7 +278,7 @@ class DockerInterface(JobGroup):
f"Can't install {image}:{version!s}: {err}", _LOGGER.error
) from err
except (docker.errors.DockerException, requests.RequestException) as err:
capture_exception(err)
await async_capture_exception(err)
raise DockerError(
f"Unknown error with {image}:{version!s} -> {err!s}", _LOGGER.error
) from err
@ -394,7 +394,7 @@ class DockerInterface(JobGroup):
)
except DockerNotFound as err:
# If image is missing, capture the exception as this shouldn't happen
capture_exception(err)
await async_capture_exception(err)
raise
# Store metadata

View File

@ -49,17 +49,26 @@ class HwDisk(CoreSysAttributes):
return False
def get_disk_total_space(self, path: str | Path) -> float:
"""Return total space (GiB) on disk for path."""
"""Return total space (GiB) on disk for path.
Must be run in executor.
"""
total, _, _ = shutil.disk_usage(path)
return round(total / (1024.0**3), 1)
def get_disk_used_space(self, path: str | Path) -> float:
"""Return used space (GiB) on disk for path."""
"""Return used space (GiB) on disk for path.
Must be run in executor.
"""
_, used, _ = shutil.disk_usage(path)
return round(used / (1024.0**3), 1)
def get_disk_free_space(self, path: str | Path) -> float:
"""Return free space (GiB) on disk for path."""
"""Return free space (GiB) on disk for path.
Must be run in executor.
"""
_, _, free = shutil.disk_usage(path)
return round(free / (1024.0**3), 1)
@ -113,7 +122,10 @@ class HwDisk(CoreSysAttributes):
return life_time_value * 10.0
def get_disk_life_time(self, path: str | Path) -> float:
"""Return life time estimate of the underlying SSD drive."""
"""Return life time estimate of the underlying SSD drive.
Must be run in executor.
"""
mount_source = self._get_mount_source(str(path))
if mount_source == "overlay":
return None

View File

@ -33,7 +33,7 @@ from ..jobs.decorator import Job, JobCondition
from ..jobs.job_group import JobGroup
from ..resolution.const import ContextType, IssueType
from ..utils import convert_to_ascii
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .const import (
LANDINGPAGE,
SAFE_MODE_FILENAME,
@ -160,7 +160,7 @@ class HomeAssistantCore(JobGroup):
except (DockerError, JobException):
pass
except Exception as err: # pylint: disable=broad-except
capture_exception(err)
await async_capture_exception(err)
_LOGGER.warning("Failed to install landingpage, retrying after 30sec")
await asyncio.sleep(30)
@ -192,7 +192,7 @@ class HomeAssistantCore(JobGroup):
except (DockerError, JobException):
pass
except Exception as err: # pylint: disable=broad-except
capture_exception(err)
await async_capture_exception(err)
_LOGGER.warning("Error on Home Assistant installation. Retrying in 30sec")
await asyncio.sleep(30)
@ -557,7 +557,7 @@ class HomeAssistantCore(JobGroup):
try:
await self.start()
except HomeAssistantError as err:
capture_exception(err)
await async_capture_exception(err)
else:
break
@ -569,7 +569,7 @@ class HomeAssistantCore(JobGroup):
except HomeAssistantError as err:
attempts = attempts + 1
_LOGGER.error("Watchdog restart of Home Assistant failed!")
capture_exception(err)
await async_capture_exception(err)
else:
break

View File

@ -102,39 +102,39 @@ class InfoCenter(CoreSysAttributes):
"""Return the boot timestamp."""
return self.sys_dbus.systemd.boot_timestamp
@property
def total_space(self) -> float:
"""Return total space (GiB) on disk for supervisor data directory."""
return self.sys_hardware.disk.get_disk_total_space(
self.coresys.config.path_supervisor
)
@property
def used_space(self) -> float:
"""Return used space (GiB) on disk for supervisor data directory."""
return self.sys_hardware.disk.get_disk_used_space(
self.coresys.config.path_supervisor
)
@property
def free_space(self) -> float:
"""Return available space (GiB) on disk for supervisor data directory."""
return self.sys_hardware.disk.get_disk_free_space(
self.coresys.config.path_supervisor
)
@property
def disk_life_time(self) -> float:
"""Return the estimated life-time usage (in %) of the SSD storing the data directory."""
return self.sys_hardware.disk.get_disk_life_time(
self.coresys.config.path_supervisor
)
@property
def virtualization(self) -> str | None:
"""Return virtualization hypervisor being used."""
return self.sys_dbus.systemd.virtualization
async def total_space(self) -> float:
"""Return total space (GiB) on disk for supervisor data directory."""
return await self.sys_run_in_executor(
self.sys_hardware.disk.get_disk_total_space,
self.coresys.config.path_supervisor,
)
async def used_space(self) -> float:
"""Return used space (GiB) on disk for supervisor data directory."""
return await self.sys_run_in_executor(
self.sys_hardware.disk.get_disk_used_space,
self.coresys.config.path_supervisor,
)
async def free_space(self) -> float:
"""Return available space (GiB) on disk for supervisor data directory."""
return await self.sys_run_in_executor(
self.sys_hardware.disk.get_disk_free_space,
self.coresys.config.path_supervisor,
)
async def disk_life_time(self) -> float:
"""Return the estimated life-time usage (in %) of the SSD storing the data directory."""
return await self.sys_run_in_executor(
self.sys_hardware.disk.get_disk_life_time,
self.coresys.config.path_supervisor,
)
async def get_dmesg(self) -> bytes:
"""Return host dmesg output."""
proc = await asyncio.create_subprocess_shell(

View File

@ -20,7 +20,6 @@ from ..exceptions import HassioError, JobNotFound, JobStartException
from ..homeassistant.const import WSEvent
from ..utils.common import FileConfiguration
from ..utils.dt import utcnow
from ..utils.sentry import capture_exception
from .const import ATTR_IGNORE_CONDITIONS, FILE_CONFIG_JOBS, JobCondition
from .validate import SCHEMA_JOBS_CONFIG
@ -191,9 +190,10 @@ class JobManager(FileConfiguration, CoreSysAttributes):
"""
try:
return self.get_job(_CURRENT_JOB.get())
except (LookupError, JobNotFound) as err:
capture_exception(err)
raise RuntimeError("No job for the current asyncio task!") from None
except (LookupError, JobNotFound):
raise RuntimeError(
"No job for the current asyncio task!", _LOGGER.critical
) from None
@property
def is_job(self) -> bool:

View File

@ -18,7 +18,7 @@ from ..exceptions import (
)
from ..host.const import HostFeature
from ..resolution.const import MINIMUM_FREE_SPACE_THRESHOLD, ContextType, IssueType
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from . import SupervisorJob
from .const import JobCondition, JobExecutionLimit
from .job_group import JobGroup
@ -313,7 +313,7 @@ class Job(CoreSysAttributes):
except Exception as err:
_LOGGER.exception("Unhandled exception: %s", err)
job.capture_error()
capture_exception(err)
await async_capture_exception(err)
raise JobException() from err
finally:
self._release_exception_limits()
@ -373,13 +373,14 @@ class Job(CoreSysAttributes):
if (
JobCondition.FREE_SPACE in used_conditions
and coresys.sys_host.info.free_space < MINIMUM_FREE_SPACE_THRESHOLD
and (free_space := await coresys.sys_host.info.free_space())
< MINIMUM_FREE_SPACE_THRESHOLD
):
coresys.sys_resolution.create_issue(
IssueType.FREE_SPACE, ContextType.SYSTEM
)
raise JobConditionException(
f"'{method_name}' blocked from execution, not enough free space ({coresys.sys_host.info.free_space}GB) left on the device"
f"'{method_name}' blocked from execution, not enough free space ({free_space}GB) left on the device"
)
if JobCondition.INTERNET_SYSTEM in used_conditions:

View File

@ -80,7 +80,9 @@ def filter_data(coresys: CoreSys, event: dict, hint: dict) -> dict:
"arch": coresys.arch.default,
"board": coresys.os.board,
"deployment": coresys.host.info.deployment,
"disk_free_space": coresys.host.info.free_space,
"disk_free_space": coresys.hardware.disk.get_disk_free_space(
coresys.config.path_supervisor
),
"host": coresys.host.info.operating_system,
"kernel": coresys.host.info.kernel,
"machine": coresys.machine,

View File

@ -19,7 +19,7 @@ from ..homeassistant.const import LANDINGPAGE
from ..jobs.decorator import Job, JobCondition, JobExecutionLimit
from ..plugins.const import PLUGIN_UPDATE_CONDITIONS
from ..utils.dt import utcnow
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
_LOGGER: logging.Logger = logging.getLogger(__name__)
@ -224,7 +224,7 @@ class Tasks(CoreSysAttributes):
await self.sys_homeassistant.core.restart()
except HomeAssistantError as err:
if reanimate_fails == 0 or safe_mode:
capture_exception(err)
await async_capture_exception(err)
if safe_mode:
_LOGGER.critical(
@ -341,7 +341,7 @@ class Tasks(CoreSysAttributes):
await (await addon.restart())
except AddonsError as err:
_LOGGER.error("%s watchdog reanimation failed with %s", addon.slug, err)
capture_exception(err)
await async_capture_exception(err)
finally:
self._cache[addon.slug] = 0

View File

@ -18,7 +18,7 @@ from ..jobs.const import JobCondition
from ..jobs.decorator import Job
from ..resolution.const import SuggestionType
from ..utils.common import FileConfiguration
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .const import (
ATTR_DEFAULT_BACKUP_MOUNT,
ATTR_MOUNTS,
@ -177,7 +177,7 @@ class MountManager(FileConfiguration, CoreSysAttributes):
if mounts[i].failed_issue in self.sys_resolution.issues:
continue
if not isinstance(errors[i], MountError):
capture_exception(errors[i])
await async_capture_exception(errors[i])
self.sys_resolution.add_issue(
evolve(mounts[i].failed_issue),

View File

@ -40,7 +40,7 @@ from ..exceptions import (
)
from ..resolution.const import ContextType, IssueType
from ..resolution.data import Issue
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .const import (
ATTR_PATH,
ATTR_READ_ONLY,
@ -208,7 +208,7 @@ class Mount(CoreSysAttributes, ABC):
try:
self._state = await self.unit.get_active_state()
except DBusError as err:
capture_exception(err)
await async_capture_exception(err)
raise MountError(
f"Could not get active state of mount due to: {err!s}"
) from err
@ -221,7 +221,7 @@ class Mount(CoreSysAttributes, ABC):
self._unit = None
self._state = None
except DBusError as err:
capture_exception(err)
await async_capture_exception(err)
raise MountError(f"Could not get mount unit due to: {err!s}") from err
return self.unit

View File

@ -26,7 +26,7 @@ from ..jobs.const import JobCondition, JobExecutionLimit
from ..jobs.decorator import Job
from ..resolution.checks.disabled_data_disk import CheckDisabledDataDisk
from ..resolution.checks.multiple_data_disks import CheckMultipleDataDisks
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .const import (
FILESYSTEM_LABEL_DATA_DISK,
FILESYSTEM_LABEL_DISABLED_DATA_DISK,
@ -337,7 +337,7 @@ class DataDisk(CoreSysAttributes):
try:
await block_device.format(FormatType.GPT)
except DBusError as err:
capture_exception(err)
await async_capture_exception(err)
raise HassOSDataDiskError(
f"Could not format {new_disk.id}: {err!s}", _LOGGER.error
) from err
@ -354,7 +354,7 @@ class DataDisk(CoreSysAttributes):
0, 0, LINUX_DATA_PARTITION_GUID, PARTITION_NAME_EXTERNAL_DATA_DISK
)
except DBusError as err:
capture_exception(err)
await async_capture_exception(err)
raise HassOSDataDiskError(
f"Could not create new data partition: {err!s}", _LOGGER.error
) from err

View File

@ -24,7 +24,7 @@ from ..exceptions import (
from ..jobs.const import JobCondition, JobExecutionLimit
from ..jobs.decorator import Job
from ..resolution.const import UnhealthyReason
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .data_disk import DataDisk
_LOGGER: logging.Logger = logging.getLogger(__name__)
@ -385,7 +385,7 @@ class OSManager(CoreSysAttributes):
RaucState.ACTIVE, self.get_slot_name(boot_name)
)
except DBusError as err:
capture_exception(err)
await async_capture_exception(err)
raise HassOSSlotUpdateError(
f"Can't mark {boot_name} as active!", _LOGGER.error
) from err

View File

@ -27,7 +27,7 @@ from ..jobs.const import JobExecutionLimit
from ..jobs.decorator import Job
from ..resolution.const import UnhealthyReason
from ..utils.json import write_json_file
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .base import PluginBase
from .const import (
FILE_HASSIO_AUDIO,
@ -163,7 +163,7 @@ class PluginAudio(PluginBase):
await self.instance.install(self.version)
except DockerError as err:
_LOGGER.error("Repair of Audio failed")
capture_exception(err)
await async_capture_exception(err)
def pulse_client(self, input_profile=None, output_profile=None) -> str:
"""Generate an /etc/pulse/client.conf data."""

View File

@ -15,7 +15,7 @@ from ..docker.interface import DockerInterface
from ..docker.monitor import DockerContainerStateEvent
from ..exceptions import DockerError, PluginError
from ..utils.common import FileConfiguration
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .const import WATCHDOG_MAX_ATTEMPTS, WATCHDOG_RETRY_SECONDS
_LOGGER: logging.Logger = logging.getLogger(__name__)
@ -129,7 +129,7 @@ class PluginBase(ABC, FileConfiguration, CoreSysAttributes):
except PluginError as err:
attempts = attempts + 1
_LOGGER.error("Watchdog restart of %s plugin failed!", self.slug)
capture_exception(err)
await async_capture_exception(err)
else:
break

View File

@ -17,7 +17,7 @@ from ..docker.stats import DockerStats
from ..exceptions import CliError, CliJobError, CliUpdateError, DockerError
from ..jobs.const import JobExecutionLimit
from ..jobs.decorator import Job
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .base import PluginBase
from .const import (
FILE_HASSIO_CLI,
@ -114,7 +114,7 @@ class PluginCli(PluginBase):
await self.instance.install(self.version)
except DockerError as err:
_LOGGER.error("Repair of HA cli failed")
capture_exception(err)
await async_capture_exception(err)
@Job(
name="plugin_cli_restart_after_problem",

View File

@ -33,7 +33,7 @@ from ..jobs.const import JobExecutionLimit
from ..jobs.decorator import Job
from ..resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason
from ..utils.json import write_json_file
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from ..validate import dns_url
from .base import PluginBase
from .const import (
@ -410,7 +410,7 @@ class PluginDns(PluginBase):
await self.instance.install(self.version)
except DockerError as err:
_LOGGER.error("Repair of CoreDNS failed")
capture_exception(err)
await async_capture_exception(err)
def _write_resolv(self, resolv_conf: Path) -> None:
"""Update/Write resolv.conf file."""

View File

@ -7,7 +7,7 @@ from typing import Self
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import HassioError
from ..resolution.const import ContextType, IssueType, SuggestionType
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .audio import PluginAudio
from .base import PluginBase
from .cli import PluginCli
@ -80,7 +80,7 @@ class PluginManager(CoreSysAttributes):
reference=plugin.slug,
suggestions=[SuggestionType.EXECUTE_REPAIR],
)
capture_exception(err)
await async_capture_exception(err)
# Exit if supervisor out of date. Plugins can't update until then
if self.sys_supervisor.need_update:
@ -114,7 +114,7 @@ class PluginManager(CoreSysAttributes):
)
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Can't update plugin %s: %s", plugin.slug, err)
capture_exception(err)
await async_capture_exception(err)
async def repair(self) -> None:
"""Repair Supervisor plugins."""
@ -132,4 +132,4 @@ class PluginManager(CoreSysAttributes):
await plugin.stop()
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Can't stop plugin %s: %s", plugin.slug, err)
capture_exception(err)
await async_capture_exception(err)

View File

@ -19,7 +19,7 @@ from ..exceptions import (
)
from ..jobs.const import JobExecutionLimit
from ..jobs.decorator import Job
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .base import PluginBase
from .const import (
FILE_HASSIO_MULTICAST,
@ -109,7 +109,7 @@ class PluginMulticast(PluginBase):
await self.instance.install(self.version)
except DockerError as err:
_LOGGER.error("Repair of Multicast failed")
capture_exception(err)
await async_capture_exception(err)
@Job(
name="plugin_multicast_restart_after_problem",

View File

@ -22,7 +22,7 @@ from ..exceptions import (
)
from ..jobs.const import JobExecutionLimit
from ..jobs.decorator import Job
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .base import PluginBase
from .const import (
FILE_HASSIO_OBSERVER,
@ -121,7 +121,7 @@ class PluginObserver(PluginBase):
await self.instance.install(self.version)
except DockerError as err:
_LOGGER.error("Repair of HA observer failed")
capture_exception(err)
await async_capture_exception(err)
@Job(
name="plugin_observer_restart_after_problem",

View File

@ -7,7 +7,7 @@ from typing import Any
from ..const import ATTR_CHECKS
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import ResolutionNotFound
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .checks.base import CheckBase
from .validate import get_valid_modules
@ -64,6 +64,6 @@ class ResolutionCheck(CoreSysAttributes):
await check()
except Exception as err: # pylint: disable=broad-except
_LOGGER.error("Error during processing %s: %s", check.issue, err)
capture_exception(err)
await async_capture_exception(err)
_LOGGER.info("System checks complete")

View File

@ -10,7 +10,7 @@ from ...const import CoreState
from ...coresys import CoreSys
from ...jobs.const import JobCondition, JobExecutionLimit
from ...jobs.decorator import Job
from ...utils.sentry import capture_exception
from ...utils.sentry import async_capture_exception
from ..const import DNS_CHECK_HOST, ContextType, IssueType
from .base import CheckBase
@ -42,7 +42,7 @@ class CheckDNSServer(CheckBase):
ContextType.DNS_SERVER,
reference=dns_servers[i],
)
capture_exception(results[i])
await async_capture_exception(results[i])
@Job(name="check_dns_server_approve", conditions=[JobCondition.INTERNET_SYSTEM])
async def approve_check(self, reference: str | None = None) -> bool:

View File

@ -10,7 +10,7 @@ from ...const import CoreState
from ...coresys import CoreSys
from ...jobs.const import JobCondition, JobExecutionLimit
from ...jobs.decorator import Job
from ...utils.sentry import capture_exception
from ...utils.sentry import async_capture_exception
from ..const import DNS_CHECK_HOST, DNS_ERROR_NO_DATA, ContextType, IssueType
from .base import CheckBase
@ -47,7 +47,7 @@ class CheckDNSServerIPv6(CheckBase):
ContextType.DNS_SERVER,
reference=dns_servers[i],
)
capture_exception(results[i])
await async_capture_exception(results[i])
@Job(
name="check_dns_server_ipv6_approve", conditions=[JobCondition.INTERNET_SYSTEM]

View File

@ -23,7 +23,7 @@ class CheckFreeSpace(CheckBase):
async def run_check(self) -> None:
"""Run check if not affected by issue."""
if self.sys_host.info.free_space > MINIMUM_FREE_SPACE_THRESHOLD:
if await self.sys_host.info.free_space() > MINIMUM_FREE_SPACE_THRESHOLD:
return
suggestions: list[SuggestionType] = []
@ -45,7 +45,7 @@ class CheckFreeSpace(CheckBase):
async def approve_check(self, reference: str | None = None) -> bool:
"""Approve check if it is affected by issue."""
if self.sys_host.info.free_space > MINIMUM_FREE_SPACE_THRESHOLD:
if await self.sys_host.info.free_space() > MINIMUM_FREE_SPACE_THRESHOLD:
return False
return True

View File

@ -5,7 +5,7 @@ import logging
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import ResolutionNotFound
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .const import UnhealthyReason, UnsupportedReason
from .evaluations.base import EvaluateBase
from .validate import get_valid_modules
@ -64,7 +64,7 @@ class ResolutionEvaluation(CoreSysAttributes):
_LOGGER.warning(
"Error during processing %s: %s", evaluation.reason, err
)
capture_exception(err)
await async_capture_exception(err)
if any(reason in self.sys_resolution.unsupported for reason in UNHEALTHY):
self.sys_resolution.unhealthy = UnhealthyReason.DOCKER

View File

@ -6,7 +6,7 @@ import logging
from ..coresys import CoreSys, CoreSysAttributes
from ..jobs.const import JobCondition
from ..jobs.decorator import Job
from ..utils.sentry import capture_exception
from ..utils.sentry import async_capture_exception
from .data import Issue, Suggestion
from .fixups.base import FixupBase
from .validate import get_valid_modules
@ -55,7 +55,7 @@ class ResolutionFixup(CoreSysAttributes):
await fix()
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Error during processing %s: %s", fix.suggestion, err)
capture_exception(err)
await async_capture_exception(err)
_LOGGER.info("System autofix complete")

View File

@ -36,7 +36,7 @@ class ResolutionNotify(CoreSysAttributes):
messages.append(
{
"title": "Available space is less than 1GB!",
"message": f"Available space is {self.sys_host.info.free_space}GB, see https://www.home-assistant.io/more-info/free-space for more information.",
"message": f"Available space is {await self.sys_host.info.free_space()}GB, see https://www.home-assistant.io/more-info/free-space for more information.",
"notification_id": "supervisor_issue_free_space",
}
)

View File

@ -36,7 +36,7 @@ from .jobs.const import JobCondition, JobExecutionLimit
from .jobs.decorator import Job
from .resolution.const import ContextType, IssueType, UnhealthyReason
from .utils.codenotary import calc_checksum
from .utils.sentry import capture_exception
from .utils.sentry import async_capture_exception
_LOGGER: logging.Logger = logging.getLogger(__name__)
@ -219,7 +219,7 @@ class Supervisor(CoreSysAttributes):
self.sys_resolution.create_issue(
IssueType.UPDATE_FAILED, ContextType.SUPERVISOR
)
capture_exception(err)
await async_capture_exception(err)
raise SupervisorUpdateError(
f"Update of Supervisor failed: {err!s}", _LOGGER.critical
) from err

View File

@ -35,7 +35,7 @@ from ..exceptions import (
DBusTimeoutError,
HassioNotSupportedError,
)
from .sentry import capture_exception
from .sentry import async_capture_exception
_LOGGER: logging.Logger = logging.getLogger(__name__)
@ -124,7 +124,7 @@ class DBus:
)
raise DBus.from_dbus_error(err) from None
except Exception as err: # pylint: disable=broad-except
capture_exception(err)
await async_capture_exception(err)
raise DBusFatalError(str(err)) from err
def _add_interfaces(self):

View File

@ -3,8 +3,6 @@
import logging
import re
from .sentry import capture_exception
_LOGGER: logging.Logger = logging.getLogger(__name__)
RE_BIND_FAILED = re.compile(
@ -13,13 +11,11 @@ RE_BIND_FAILED = re.compile(
def format_message(message: str) -> str:
"""Return a formated message if it's known."""
try:
match = RE_BIND_FAILED.match(message)
if match:
return f"Port '{match.group(1)}' is already in use by something else on the host."
except TypeError as err:
_LOGGER.error("The type of message is not a string - %s", err)
capture_exception(err)
"""Return a formatted message if it's known."""
match = RE_BIND_FAILED.match(message)
if match:
return (
f"Port '{match.group(1)}' is already in use by something else on the host."
)
return message

View File

@ -1,5 +1,6 @@
"""Utilities for sentry."""
import asyncio
from functools import partial
import logging
from typing import Any
@ -46,19 +47,43 @@ def init_sentry(coresys: CoreSys) -> None:
def capture_event(event: dict[str, Any], only_once: str | None = None):
"""Capture an event and send to sentry."""
"""Capture an event and send to sentry.
Must be called in executor.
"""
if sentry_sdk.is_initialized():
if only_once and only_once not in only_once_events:
only_once_events.add(only_once)
sentry_sdk.capture_event(event)
async def async_capture_event(event: dict[str, Any], only_once: str | None = None):
"""Capture an event and send to sentry.
Safe to call from event loop.
"""
await asyncio.get_running_loop().run_in_executor(
None, capture_event, event, only_once
)
def capture_exception(err: Exception) -> None:
"""Capture an exception and send to sentry."""
"""Capture an exception and send to sentry.
Must be called in executor.
"""
if sentry_sdk.is_initialized():
sentry_sdk.capture_exception(err)
async def async_capture_exception(err: Exception) -> None:
"""Capture an exception and send to sentry.
Safe to call in event loop.
"""
await asyncio.get_running_loop().run_in_executor(None, capture_exception, err)
def close_sentry() -> None:
"""Close the current sentry client.

View File

@ -216,7 +216,7 @@ async def test_api_supervisor_fallback_log_capture(
"No systemd-journal-gatewayd Unix socket available!"
)
with patch("supervisor.api.capture_exception") as capture_exception:
with patch("supervisor.api.async_capture_exception") as capture_exception:
await api_client.get("/supervisor/logs")
capture_exception.assert_not_called()
@ -224,7 +224,7 @@ async def test_api_supervisor_fallback_log_capture(
journald_logs.side_effect = HassioError("Something bad happened!")
with patch("supervisor.api.capture_exception") as capture_exception:
with patch("supervisor.api.async_capture_exception") as capture_exception:
await api_client.get("/supervisor/logs")
capture_exception.assert_called_once()

View File

@ -141,7 +141,7 @@ async def test_home_assistant_watchdog_rebuild_on_failure(coresys: CoreSys) -> N
time=1,
),
)
await asyncio.sleep(0)
await asyncio.sleep(0.1)
start.assert_called_once()
rebuild.assert_called_once()

View File

@ -5,10 +5,10 @@ from unittest.mock import patch
from supervisor.host.info import InfoCenter
def test_host_free_space(coresys):
async def test_host_free_space(coresys):
"""Test host free space."""
info = InfoCenter(coresys)
with patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))):
free = info.free_space
free = await info.free_space()
assert free == 2.0

View File

@ -19,9 +19,3 @@ def test_format_message_port_alternative():
format_message(message)
== "Port '80' is already in use by something else on the host."
)
def test_exeption():
"""Tests the exception handling."""
message = b"byte"
assert format_message(message) == message