From 1996bc425f27de225d2b9baf7b394c07ed3ac657 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:46:24 -0500 Subject: [PATCH] [core] Fix state leakage and module caching when processing multiple configurations (#13368) Co-authored-by: Claude Opus 4.5 --- esphome/__main__.py | 112 ++++++++++++++++++++++------------ tests/unit_tests/test_main.py | 66 +++++++++++++++++++- 2 files changed, 139 insertions(+), 39 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 545464be10..09d2855eb1 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1,5 +1,6 @@ # PYTHON_ARGCOMPLETE_OK import argparse +from collections.abc import Callable from datetime import datetime import functools import getpass @@ -936,11 +937,21 @@ def command_dashboard(args: ArgsProtocol) -> int | None: return dashboard.start_dashboard(args) -def command_update_all(args: ArgsProtocol) -> int | None: +def run_multiple_configs( + files: list, command_builder: Callable[[str], list[str]] +) -> int: + """Run a command for each configuration file in a subprocess. + + Args: + files: List of configuration files to process. + command_builder: Callable that takes a file path and returns a command list. + + Returns: + Number of failed files. + """ import click success = {} - files = list_yaml_files(args.configuration) twidth = 60 def print_bar(middle_text): @@ -950,17 +961,19 @@ def command_update_all(args: ArgsProtocol) -> int | None: safe_print(f"{half_line}{middle_text}{half_line}") for f in files: - safe_print(f"Updating {color(AnsiFore.CYAN, str(f))}") + f_path = Path(f) if not isinstance(f, Path) else f + + if any(f_path.name == x for x in SECRETS_FILES): + _LOGGER.warning("Skipping secrets file %s", f_path) + continue + + safe_print(f"Processing {color(AnsiFore.CYAN, str(f))}") safe_print("-" * twidth) safe_print() - if CORE.dashboard: - rc = run_external_process( - "esphome", "--dashboard", "run", f, "--no-logs", "--device", "OTA" - ) - else: - rc = run_external_process( - "esphome", "run", f, "--no-logs", "--device", "OTA" - ) + + cmd = command_builder(f) + rc = run_external_process(*cmd) + if rc == 0: print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {str(f)}") success[f] = True @@ -975,6 +988,8 @@ def command_update_all(args: ArgsProtocol) -> int | None: print_bar(f"[{color(AnsiFore.BOLD_WHITE, 'SUMMARY')}]") failed = 0 for f in files: + if f not in success: + continue # Skipped file if success[f]: safe_print(f" - {str(f)}: {color(AnsiFore.GREEN, 'SUCCESS')}") else: @@ -983,6 +998,17 @@ def command_update_all(args: ArgsProtocol) -> int | None: return failed +def command_update_all(args: ArgsProtocol) -> int | None: + files = list_yaml_files(args.configuration) + + def build_command(f): + if CORE.dashboard: + return ["esphome", "--dashboard", "run", f, "--no-logs", "--device", "OTA"] + return ["esphome", "run", f, "--no-logs", "--device", "OTA"] + + return run_multiple_configs(files, build_command) + + def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json @@ -1533,38 +1559,48 @@ def run_esphome(argv): _LOGGER.info("ESPHome %s", const.__version__) - for conf_path in args.configuration: - conf_path = Path(conf_path) - if any(conf_path.name == x for x in SECRETS_FILES): - _LOGGER.warning("Skipping secrets file %s", conf_path) - continue + # Multiple configurations: use subprocesses to avoid state leakage + # between compilations (e.g., LVGL touchscreen state in module globals) + if len(args.configuration) > 1: + # Build command by reusing argv, replacing all configs with single file + # argv[0] is the program path, skip it since we prefix with "esphome" + def build_command(f): + return ( + ["esphome"] + + [arg for arg in argv[1:] if arg not in args.configuration] + + [str(f)] + ) - CORE.config_path = conf_path - CORE.dashboard = args.dashboard + return run_multiple_configs(args.configuration, build_command) - # For logs command, skip updating external components - skip_external = args.command == "logs" - config = read_config( - dict(args.substitution) if args.substitution else {}, - skip_external_update=skip_external, - ) - if config is None: - return 2 - CORE.config = config + # Single configuration + conf_path = Path(args.configuration[0]) + if any(conf_path.name == x for x in SECRETS_FILES): + _LOGGER.warning("Skipping secrets file %s", conf_path) + return 0 - if args.command not in POST_CONFIG_ACTIONS: - safe_print(f"Unknown command {args.command}") + CORE.config_path = conf_path + CORE.dashboard = args.dashboard - try: - rc = POST_CONFIG_ACTIONS[args.command](args, config) - except EsphomeError as e: - _LOGGER.error(e, exc_info=args.verbose) - return 1 - if rc != 0: - return rc + # For logs command, skip updating external components + skip_external = args.command == "logs" + config = read_config( + dict(args.substitution) if args.substitution else {}, + skip_external_update=skip_external, + ) + if config is None: + return 2 + CORE.config = config - CORE.reset() - return 0 + if args.command not in POST_CONFIG_ACTIONS: + safe_print(f"Unknown command {args.command}") + return 1 + + try: + return POST_CONFIG_ACTIONS[args.command](args, config) + except EsphomeError as e: + _LOGGER.error(e, exc_info=args.verbose) + return 1 def main(): diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index fd8f04ded5..3268f7ee87 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -34,6 +34,7 @@ from esphome.__main__ import ( has_non_ip_address, has_resolvable_address, mqtt_get_ip, + run_esphome, run_miniterm, show_logs, upload_program, @@ -1988,7 +1989,7 @@ esp32: clean_output = strip_ansi_codes(captured.out) assert "test-device_123.yaml" in clean_output - assert "Updating" in clean_output + assert "Processing" in clean_output assert "SUCCESS" in clean_output assert "SUMMARY" in clean_output @@ -3172,3 +3173,66 @@ def test_run_miniterm_buffer_limit_prevents_unbounded_growth() -> None: x_count = printed_line.count("X") assert x_count < 150, f"Expected truncation but got {x_count} X's" assert x_count == 95, f"Expected 95 X's after truncation but got {x_count}" + + +def test_run_esphome_multiple_configs_with_secrets( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test run_esphome with multiple configs and secrets file. + + Verifies: + - Multiple configs use subprocess isolation + - Secrets files are skipped with warning + - Secrets files don't appear in summary + """ + # Create two config files and a secrets file + yaml_file1 = tmp_path / "device1.yaml" + yaml_file1.write_text(""" +esphome: + name: device1 + +esp32: + board: nodemcu-32s +""") + yaml_file2 = tmp_path / "device2.yaml" + yaml_file2.write_text(""" +esphome: + name: device2 + +esp32: + board: nodemcu-32s +""") + secrets_file = tmp_path / "secrets.yaml" + secrets_file.write_text("wifi_password: secret123\n") + + setup_core(tmp_path=tmp_path) + mock_run_external_process.return_value = 0 + + # run_esphome expects argv[0] to be the program name (gets sliced off by parse_args) + with caplog.at_level(logging.WARNING): + result = run_esphome( + ["esphome", "compile", str(yaml_file1), str(secrets_file), str(yaml_file2)] + ) + + assert result == 0 + + # Check secrets file was skipped with warning + assert "Skipping secrets file" in caplog.text + assert "secrets.yaml" in caplog.text + + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + # Both config files should be processed + assert "device1.yaml" in clean_output + assert "device2.yaml" in clean_output + assert "SUMMARY" in clean_output + + # Secrets should not appear in summary + summary_section = ( + clean_output.split("SUMMARY")[1] if "SUMMARY" in clean_output else "" + ) + assert "secrets.yaml" not in summary_section