From 8a0ee762a6a6a317bea9b6ec8a52ffe38d17a4b7 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 17 Jul 2025 11:28:21 +0200 Subject: [PATCH] Increase open file descriptor limit on startup Increase the open file descriptor soft limit to 2048 on startup. This is necessary to prevent issues with file descriptor exhaustion in environments where the soft limit is low (e.g. the defaul of 1024 on Linux and since Home Assistant OS 16.0 in its container environment). --- homeassistant/bootstrap.py | 3 ++ homeassistant/util/resource.py | 63 +++++++++++++++++++++++ tests/util/test_resource.py | 92 ++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 homeassistant/util/resource.py create mode 100644 tests/util/test_resource.py 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] + )