diff --git a/esphome/__main__.py b/esphome/__main__.py index 7879cdad0c..5d6cf2972a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -569,6 +569,7 @@ def wrap_to_code(name, comp): @functools.wraps(comp.to_code) async def wrapped(conf): + cg.add(cg.ComponentMarker(name)) cg.add(cg.LineComment(f"{name}:")) if comp.config_schema is not None: conf_str = yaml_util.dump(conf) diff --git a/esphome/codegen.py b/esphome/codegen.py index a5b5abe447..ffaf368bd4 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -10,6 +10,7 @@ # pylint: disable=unused-import from esphome.cpp_generator import ( # noqa: F401 ArrayInitializer, + ComponentMarker, Expression, FlashStringLiteral, LineComment, diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 009fef2f86..43c1ffaed0 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -531,6 +531,39 @@ class Library: return self +def _wrap_in_noinline_iifes(lines: list[str], max_statements: int) -> list[str]: + """Wrap ``lines`` in one or more ``[]() [[gnu::noinline]] { ... }();`` IIFEs. + + Splits into IIFEs of up to ``max_statements`` entries each. Never splits + inside a brace-balanced block (e.g. the ``{`` / ``}`` pair that + ``cg.with_local_variable()`` emits around a scoped local), so an IIFE + may exceed ``max_statements`` when a block straddles the boundary. + """ + out: list[str] = [] + chunk: list[str] = [] + depth = 0 + + def flush() -> None: + if not chunk: + return + out.append("[]() [[gnu::noinline]] {") + out.extend(chunk) + out.append("}();") + chunk.clear() + + for line in lines: + chunk.append(line) + stripped = line.strip() + if stripped == "{": + depth += 1 + elif stripped == "}": + depth -= 1 + if depth == 0 and len(chunk) >= max_statements: + flush() + flush() + return out + + # pylint: disable=too-many-public-methods class EsphomeCore: def __init__(self): @@ -1003,14 +1036,32 @@ class EsphomeCore: @property def cpp_main_section(self): - from esphome.cpp_generator import statement + from esphome.cpp_generator import ComponentMarker, statement - main_code = [] + # Split main_statements at ComponentMarker sentinels into a prefix + # (statements emitted before any component) plus per-component groups. + prefix: list[str] = [] + components: list[list[str]] = [] + current = prefix for exp in self.main_statements: - text = str(statement(exp)) - text = text.rstrip() - main_code.append(text) - return "\n".join(main_code) + "\n\n" + if isinstance(exp, ComponentMarker): + current = [str(exp).rstrip()] + components.append(current) + continue + current.append(str(statement(exp)).rstrip()) + + # No components → flat output (host build, tests). + if not components: + return "\n".join(prefix) + "\n\n" + + # Each component's block is wrapped in a noinline IIFE lambda so its + # stack frame is released on return, bounding peak stack during + # setup(). Large blocks are sub-split to cap single heavy components + # (e.g. sensor platforms with many filter registrations). + pieces = list(prefix) + for block in components: + pieces.extend(_wrap_in_noinline_iifes(block, max_statements=50)) + return "\n".join(pieces) + "\n\n" @property def cpp_global_section(self): diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index cf90b878e1..d7205d1d8f 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -434,6 +434,21 @@ class LineComment(Statement): return "\n".join(parts) +class ComponentMarker(Statement): + """Sentinel marker recorded in main_statements when a component's + to_code begins emitting code. Used by cpp_main_section to split + setup() output into per-component chunks, so each component's + stack frame is released on return.""" + + __slots__ = ("name",) + + def __init__(self, name: str): + self.name = name + + def __str__(self): + return f"// === {self.name} ===" + + class ProgmemAssignmentExpression(AssignmentExpression): __slots__ = () diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 22be59653a..1c31f24066 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -7,6 +7,7 @@ import pytest from strategies import mac_addr_strings from esphome import const, core +from esphome.cpp_generator import ComponentMarker, RawStatement class TestHexInt: @@ -867,3 +868,102 @@ class TestEsphomeCore: mock_enable.assert_called_once_with("Wire") assert "Wire" in target.platformio_libraries + + +def test_wrap_in_noinline_iifes_empty_input() -> None: + assert not core._wrap_in_noinline_iifes([], max_statements=10) + + +def test_wrap_in_noinline_iifes_fewer_lines_than_limit() -> None: + lines = ["a();", "b();", "c();"] + assert core._wrap_in_noinline_iifes(lines, max_statements=10) == [ + "[]() [[gnu::noinline]] {", + "a();", + "b();", + "c();", + "}();", + ] + + +def test_wrap_in_noinline_iifes_splits_at_max_statements() -> None: + lines = [f"s{i}();" for i in range(5)] + result = core._wrap_in_noinline_iifes(lines, max_statements=2) + # With max=2 and 5 lines: chunks of 2, 2, 1 → 3 IIFEs. + assert sum(1 for line in result if line.startswith("[]()")) == 3 + + +def test_wrap_in_noinline_iifes_never_splits_inside_braces() -> None: + # max=2 would naively split after "{" but brace guard keeps block whole. + lines = ["a();", "{", "inner();", "}", "b();"] + assert core._wrap_in_noinline_iifes(lines, max_statements=2) == [ + "[]() [[gnu::noinline]] {", + "a();", + "{", + "inner();", + "}", + "}();", + "[]() [[gnu::noinline]] {", + "b();", + "}();", + ] + + +def test_wrap_in_noinline_iifes_nested_braces() -> None: + lines = ["{", "{", "deep();", "}", "}", "after();"] + assert core._wrap_in_noinline_iifes(lines, max_statements=1) == [ + "[]() [[gnu::noinline]] {", + "{", + "{", + "deep();", + "}", + "}", + "}();", + "[]() [[gnu::noinline]] {", + "after();", + "}();", + ] + + +def test_wrap_in_noinline_iifes_unbalanced_braces_fall_through() -> None: + # Pathological input where "}" appears before "{": don't crash; emit + # a single IIFE with all lines rather than splitting mid-flight. + lines = ["a();", "}", "b();"] + result = core._wrap_in_noinline_iifes(lines, max_statements=1) + assert result[0] == "[]() [[gnu::noinline]] {" + assert result[-1] == "}();" + assert [line for line in result if line in lines] == lines + + +def test_cpp_main_section_no_components_emits_flat() -> None: + target = core.EsphomeCore() + target.main_statements = [RawStatement("a();"), RawStatement("b();")] + out = target.cpp_main_section + assert "[[gnu::noinline]]" not in out + assert "a();" in out + assert "b();" in out + + +def test_cpp_main_section_component_marker_wraps_in_iife() -> None: + target = core.EsphomeCore() + target.main_statements = [ + ComponentMarker("logger"), + RawStatement("new_logger();"), + ComponentMarker("wifi"), + RawStatement("new_wifi();"), + ] + out = target.cpp_main_section + assert out.count("[]() [[gnu::noinline]] {") == 2 + assert out.count("}();") == 2 + assert "// === logger ===" in out + assert "// === wifi ===" in out + + +def test_cpp_main_section_prefix_statements_stay_outside_iife() -> None: + target = core.EsphomeCore() + target.main_statements = [ + RawStatement("prefix();"), + ComponentMarker("c"), + RawStatement("body();"), + ] + out = target.cpp_main_section + assert out.index("prefix();") < out.index("[]() [[gnu::noinline]] {")