diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 493b9b1eab6..0df469e60bb 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -111,6 +111,7 @@ from .util.async_ import create_eager_task from .util.hass_dict import HassKey from .util.logging import async_activate_log_queue_handler from .util.package import async_get_user_site, is_docker_env, is_virtual_env +from .util.resource import set_open_file_descriptor_limit from .util.system_info import is_official_image with contextlib.suppress(ImportError): @@ -302,6 +303,8 @@ async def async_setup_hass( hass = await create_hass() + set_open_file_descriptor_limit() + if runtime_config.skip_pip or runtime_config.skip_pip_packages: _LOGGER.warning( "Skipping pip installation of required modules. This may cause issues" diff --git a/homeassistant/util/resource.py b/homeassistant/util/resource.py new file mode 100644 index 00000000000..3fbbdd3a575 --- /dev/null +++ b/homeassistant/util/resource.py @@ -0,0 +1,63 @@ +"""Resource management utilities for Home Assistant.""" + +from __future__ import annotations + +import logging +import os +import resource +from typing import Final + +_LOGGER = logging.getLogger(__name__) + +# Default soft file descriptor limit to set +DEFAULT_SOFT_FILE_LIMIT: Final = 2048 + + +def set_open_file_descriptor_limit() -> None: + """Set the maximum open file descriptor soft limit.""" + # Check environment variable first, then use default + soft_limit = int(os.environ.get("SOFT_FILE_LIMIT", DEFAULT_SOFT_FILE_LIMIT)) + + try: + # Get current limits + current_soft, current_hard = resource.getrlimit(resource.RLIMIT_NOFILE) + + _LOGGER.debug( + "Current file descriptor limits: soft=%d, hard=%d", + current_soft, + current_hard, + ) + + # Don't increase if already at or above the desired limit + if current_soft >= soft_limit: + _LOGGER.debug( + "Current soft limit (%d) is already >= desired limit (%d), skipping", + current_soft, + soft_limit, + ) + return + + # Don't set soft limit higher than hard limit + if soft_limit > current_hard: + _LOGGER.warning( + "Requested soft limit (%d) exceeds hard limit (%d), " + "setting to hard limit", + soft_limit, + current_hard, + ) + soft_limit = current_hard + + # Set the new soft limit + resource.setrlimit(resource.RLIMIT_NOFILE, (soft_limit, current_hard)) + + # Verify the change + new_soft, new_hard = resource.getrlimit(resource.RLIMIT_NOFILE) + _LOGGER.info( + "File descriptor limits updated: soft=%d->%d, hard=%d", + current_soft, + new_soft, + new_hard, + ) + + except OSError as err: + _LOGGER.error("Failed to set file descriptor limit: %s", err) diff --git a/tests/util/test_resource.py b/tests/util/test_resource.py new file mode 100644 index 00000000000..f7e77e3a026 --- /dev/null +++ b/tests/util/test_resource.py @@ -0,0 +1,92 @@ +"""Test the resource utility module.""" + +import os +import resource +from unittest.mock import patch + +from homeassistant.util.resource import ( + DEFAULT_SOFT_FILE_LIMIT, + set_open_file_descriptor_limit, +) + + +def test_set_open_file_descriptor_limit_default() -> None: + """Test setting file limit with default value.""" + original_soft, original_hard = resource.getrlimit(resource.RLIMIT_NOFILE) + + with patch("homeassistant.util.resource._LOGGER") as mock_logger: + set_open_file_descriptor_limit() + + # Check that we attempted to set the limit + new_soft, new_hard = resource.getrlimit(resource.RLIMIT_NOFILE) + + # If the original soft limit was already >= DEFAULT_SOFT_FILE_LIMIT, + # it should remain unchanged + if original_soft >= DEFAULT_SOFT_FILE_LIMIT: + assert new_soft == original_soft + mock_logger.debug.assert_called() + else: + # Should have been increased to DEFAULT_SOFT_FILE_LIMIT or hard limit + expected_soft = min(DEFAULT_SOFT_FILE_LIMIT, original_hard) + assert new_soft == expected_soft + mock_logger.info.assert_called() + + +def test_set_open_file_descriptor_limit_environment_variable() -> None: + """Test setting file limit from environment variable.""" + custom_limit = 1500 + + with ( + patch.dict(os.environ, {"SOFT_FILE_LIMIT": str(custom_limit)}), + patch("homeassistant.util.resource._LOGGER") as mock_logger, + ): + original_soft, original_hard = resource.getrlimit(resource.RLIMIT_NOFILE) + + set_open_file_descriptor_limit() + + new_soft, new_hard = resource.getrlimit(resource.RLIMIT_NOFILE) + + if original_soft >= custom_limit: + assert new_soft == original_soft + mock_logger.debug.assert_called() + else: + expected_soft = min(custom_limit, original_hard) + assert new_soft == expected_soft + + +def test_set_open_file_descriptor_limit_exceeds_hard_limit() -> None: + """Test setting file limit that exceeds hard limit.""" + original_soft, original_hard = resource.getrlimit(resource.RLIMIT_NOFILE) + excessive_limit = original_hard + 1000 + + with ( + patch.dict(os.environ, {"SOFT_FILE_LIMIT": str(excessive_limit)}), + patch("homeassistant.util.resource._LOGGER") as mock_logger, + ): + set_open_file_descriptor_limit() + + new_soft, new_hard = resource.getrlimit(resource.RLIMIT_NOFILE) + + # Should be capped at hard limit + assert new_soft == original_hard + mock_logger.warning.assert_called_once() + + +def test_set_open_file_descriptor_limit_os_error() -> None: + """Test handling OSError when setting file limit.""" + with ( + patch( + "homeassistant.util.resource.resource.getrlimit", return_value=(1000, 4096) + ), + patch( + "homeassistant.util.resource.resource.setrlimit", + side_effect=OSError("Permission denied"), + ), + patch("homeassistant.util.resource._LOGGER") as mock_logger, + ): + set_open_file_descriptor_limit() + + mock_logger.error.assert_called_once() + assert ( + "Failed to set file descriptor limit" in mock_logger.error.call_args[0][0] + )