From 19f3ef763dd9a035ef7ff9da54013aec7cd0a6d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Apr 2024 11:02:53 -1000 Subject: [PATCH] Terminate scripts with until and while conditions that execute more than 10000 times (#115110) --- homeassistant/helpers/script.py | 63 +++++++++++++++++++++++++++++++++ tests/helpers/test_script.py | 52 +++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index a86df259f1155..b4e02e0e4adf7 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -286,6 +286,9 @@ def make_script_schema( cv.SCRIPT_ACTION_WAIT_TEMPLATE, ) +REPEAT_WARN_ITERATIONS = 5000 +REPEAT_TERMINATE_ITERATIONS = 10000 + async def async_validate_actions_config( hass: HomeAssistant, actions: list[ConfigType] @@ -846,6 +849,7 @@ def set_repeat_var( # pylint: disable-next=protected-access script = self._script._get_repeat_script(self._step) + warned_too_many_loops = False async def async_run_sequence(iteration, extra_msg=""): self._log("Repeating %s: Iteration %i%s", description, iteration, extra_msg) @@ -916,6 +920,36 @@ async def async_run_sequence(iteration, extra_msg=""): _LOGGER.warning("Error in 'while' evaluation:\n%s", ex) break + if iteration > 1: + if iteration > REPEAT_WARN_ITERATIONS: + if not warned_too_many_loops: + warned_too_many_loops = True + _LOGGER.warning( + "While condition %s in script `%s` looped %s times", + repeat[CONF_WHILE], + self._script.name, + REPEAT_WARN_ITERATIONS, + ) + + if iteration > REPEAT_TERMINATE_ITERATIONS: + _LOGGER.critical( + "While condition %s in script `%s` " + "terminated because it looped %s times", + repeat[CONF_WHILE], + self._script.name, + REPEAT_TERMINATE_ITERATIONS, + ) + raise _AbortScript( + f"While condition {repeat[CONF_WHILE]} " + "terminated because it looped " + f" {REPEAT_TERMINATE_ITERATIONS} times" + ) + + # If the user creates a script with a tight loop, + # yield to the event loop so the system stays + # responsive while all the cpu time is consumed. + await asyncio.sleep(0) + await async_run_sequence(iteration) elif CONF_UNTIL in repeat: @@ -934,6 +968,35 @@ async def async_run_sequence(iteration, extra_msg=""): _LOGGER.warning("Error in 'until' evaluation:\n%s", ex) break + if iteration >= REPEAT_WARN_ITERATIONS: + if not warned_too_many_loops: + warned_too_many_loops = True + _LOGGER.warning( + "Until condition %s in script `%s` looped %s times", + repeat[CONF_UNTIL], + self._script.name, + REPEAT_WARN_ITERATIONS, + ) + + if iteration >= REPEAT_TERMINATE_ITERATIONS: + _LOGGER.critical( + "Until condition %s in script `%s` " + "terminated because it looped %s times", + repeat[CONF_UNTIL], + self._script.name, + REPEAT_TERMINATE_ITERATIONS, + ) + raise _AbortScript( + f"Until condition {repeat[CONF_UNTIL]} " + "terminated because it looped " + f"{REPEAT_TERMINATE_ITERATIONS} times" + ) + + # If the user creates a script with a tight loop, + # yield to the event loop so the system stays responsive + # while all the cpu time is consumed. + await asyncio.sleep(0) + if saved_repeat_vars: self._variables["repeat"] = saved_repeat_vars else: diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 86fb84eb582df..409b3639d4303 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -2837,6 +2837,58 @@ async def test_repeat_nested( assert_action_trace(expected_trace) +@pytest.mark.parametrize( + ("condition", "check"), [("while", "above"), ("until", "below")] +) +async def test_repeat_limits( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str, check: str +) -> None: + """Test limits on repeats prevent the system from hanging.""" + event = "test_event" + events = async_capture_events(hass, event) + hass.states.async_set("sensor.test", "0.5") + + sequence = { + "repeat": { + "sequence": [ + { + "event": event, + }, + ], + } + } + sequence["repeat"][condition] = { + "condition": "numeric_state", + "entity_id": "sensor.test", + check: "0", + } + + with ( + patch.object(script, "REPEAT_WARN_ITERATIONS", 5), + patch.object(script, "REPEAT_TERMINATE_ITERATIONS", 10), + ): + script_obj = script.Script( + hass, cv.SCRIPT_SCHEMA(sequence), f"Test {condition}", "test_domain" + ) + + caplog.clear() + caplog.set_level(logging.WARNING) + + hass.async_create_task(script_obj.async_run(context=Context())) + await asyncio.wait_for(hass.async_block_till_done(), 1) + + title_condition = condition.title() + + assert f"{title_condition} condition" in caplog.text + assert f"in script `Test {condition}` looped 5 times" in caplog.text + assert ( + f"script `Test {condition}` terminated because it looped 10 times" + in caplog.text + ) + + assert len(events) == 10 + + async def test_choose_warning( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: