diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 1bc79cc412..c0e9a0051f 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -85,6 +85,75 @@ def _get_idf_tools_path() -> Path: return CORE.data_dir / "idf" +# Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply +# below the IDF tools directory: the longest file on disk (picolibc C++ +# headers) sits ~209 characters down, but the operative number is worse -- gcc +# probes its multilib include dirs via un-normalized self-relative paths +# ("bin/../lib/gcc///../../../..//include/..."), and +# Windows checks the path string as given, before collapsing "..". Measured +# worst case (riscv32, esp-15.2.0, longest multilib + no-rtti, probing +# bits/c++config.h): ~243 characters below the tools directory. Exceeding the +# limit surfaces as cryptic build failures -- missing headers ("fatal error: +# bits/c++config.h: No such file or directory") or partial extraction +# ("cannot execute 'as'"). Warn up front so the user can shorten the path or +# enable long path support. +_WINDOWS_MAX_PATH = 260 +# Measured 243 plus a small safety margin for future toolchain growth. +_TOOLCHAIN_NESTED_PATH_LEN = 245 + + +def _windows_long_paths_enabled() -> bool: + """Return True if Windows long path support is enabled in the registry.""" + try: + import winreg # pylint: disable=import-error # Windows-only module + + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Control\FileSystem", + ) as key: + value, _ = winreg.QueryValueEx(key, "LongPathsEnabled") + return value == 1 + except OSError: + return False + + +def _check_windows_path_length() -> None: + """Warn when the install path is too long for Windows' MAX_PATH limit. + + No-op off Windows or when long path support is enabled. Otherwise warns if + the deepest toolchain file would exceed the 260-character limit, which makes + ESP-IDF toolchains extract incompletely and fail to build. + """ + if platform.system() != "Windows" or _windows_long_paths_enabled(): + return + tools_path = str(_get_idf_tools_path()) + projected = len(tools_path) + _TOOLCHAIN_NESTED_PATH_LEN + if projected <= _WINDOWS_MAX_PATH: + return + _LOGGER.warning( + "ESP-IDF tools path is too long for the default Windows path limit:\n" + " %s (%d characters)\n" + "ESP-IDF toolchain paths reach up to ~%d characters deeper (including the\n" + "compiler's internal 'bin/../lib/...' relative paths), projecting to ~%d\n" + "characters -- over the %d-character limit. This causes cryptic build\n" + "failures such as:\n" + " fatal error: bits/c++config.h: No such file or directory\n" + " cannot execute 'as': CreateProcess: No such file or directory\n" + "To fix, either:\n" + " - Enable Windows long path support: set\n" + " HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\\LongPathsEnabled\n" + " to 1 and reboot, or\n" + " - Move your ESPHome project to a shorter path\n" + "Then delete the ESP-IDF tools directory above so the toolchain " + "reinstalls cleanly.", + tools_path, + len(tools_path), + _TOOLCHAIN_NESTED_PATH_LEN, + projected, + _WINDOWS_MAX_PATH, + ) + + def _get_framework_path(version: str) -> Path: """ Get the path to the ESPHome ESP-IDF framework directory for a specific version. @@ -705,6 +774,8 @@ def check_esp_idf_install( Returns: tuple of (framework_path, python_env_path) """ + _check_windows_path_length() + env = {} env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path()) env["IDF_PATH"] = "" diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 036c7c0454..d89b93f478 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -2,9 +2,12 @@ # pylint: disable=protected-access +from contextlib import contextmanager import io import json +import logging from pathlib import Path +import sys import tarfile from types import SimpleNamespace from unittest.mock import patch @@ -13,6 +16,7 @@ import pytest from esphome.espidf.framework import ( _check_stamp, + _check_windows_path_length, _clone_idf_with_submodules, _get_framework_path, _get_idf_tool_paths, @@ -22,6 +26,7 @@ from esphome.espidf.framework import ( _get_python_version, _parse_git_source, _patch_tools_json_for_linux_arm64, + _windows_long_paths_enabled, _write_idf_version_txt, _write_stamp, check_esp_idf_install, @@ -682,3 +687,116 @@ def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None: with patch("pathlib.Path.write_text", side_effect=OSError("denied")): # write failure is caught and warned, not raised _write_idf_version_txt(tmp_path, "5.1.2") + + +def _fake_winreg( + query_result: int | None = None, query_error: OSError | None = None +) -> SimpleNamespace: + """Build a minimal winreg stand-in (the real module is Windows-only).""" + + @contextmanager + def open_key(root, path): + yield "hkey" + + def query_value_ex(key, name): + if query_error is not None: + raise query_error + return query_result, 4 # (value, REG_DWORD) + + return SimpleNamespace( + HKEY_LOCAL_MACHINE=object(), + OpenKey=open_key, + QueryValueEx=query_value_ex, + ) + + +@pytest.mark.parametrize(("reg_value", "expected"), [(1, True), (0, False)]) +def test_windows_long_paths_enabled_reads_registry( + reg_value: int, expected: bool +) -> None: + with patch.dict(sys.modules, {"winreg": _fake_winreg(query_result=reg_value)}): + assert _windows_long_paths_enabled() is expected + + +def test_windows_long_paths_enabled_missing_value() -> None: + """A missing registry value (FileNotFoundError is an OSError) reads as disabled.""" + fake = _fake_winreg(query_error=FileNotFoundError("no such value")) + with patch.dict(sys.modules, {"winreg": fake}): + assert _windows_long_paths_enabled() is False + + +# 8 chars -> projected well under the 260 limit even with the ~245-char reserve +_SHORT_IDF_PATH = "C:\\e\\idf" +# 25 chars -> projected over the limit +_LONG_IDF_PATH = "C:\\Users\\bob\\.esphome\\idf" + + +def test_check_windows_path_length_noop_off_windows( + caplog: pytest.LogCaptureFixture, +) -> None: + """Off Windows the check returns before touching the registry or the path.""" + with ( + patch("esphome.espidf.framework.platform.system", return_value="Linux"), + patch( + "esphome.espidf.framework._windows_long_paths_enabled" + ) as long_paths_mock, + caplog.at_level(logging.WARNING), + ): + _check_windows_path_length() + long_paths_mock.assert_not_called() + assert not caplog.records + + +def test_check_windows_path_length_noop_when_long_paths_enabled( + caplog: pytest.LogCaptureFixture, +) -> None: + with ( + patch("esphome.espidf.framework.platform.system", return_value="Windows"), + patch( + "esphome.espidf.framework._windows_long_paths_enabled", return_value=True + ), + patch("esphome.espidf.framework._get_idf_tools_path") as get_path_mock, + caplog.at_level(logging.WARNING), + ): + _check_windows_path_length() + get_path_mock.assert_not_called() + assert not caplog.records + + +def test_check_windows_path_length_short_path_silent( + caplog: pytest.LogCaptureFixture, +) -> None: + with ( + patch("esphome.espidf.framework.platform.system", return_value="Windows"), + patch( + "esphome.espidf.framework._windows_long_paths_enabled", return_value=False + ), + patch( + "esphome.espidf.framework._get_idf_tools_path", + return_value=_SHORT_IDF_PATH, + ), + caplog.at_level(logging.WARNING), + ): + _check_windows_path_length() + assert not caplog.records + + +def test_check_windows_path_length_long_path_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + with ( + patch("esphome.espidf.framework.platform.system", return_value="Windows"), + patch( + "esphome.espidf.framework._windows_long_paths_enabled", return_value=False + ), + patch( + "esphome.espidf.framework._get_idf_tools_path", + return_value=_LONG_IDF_PATH, + ), + caplog.at_level(logging.WARNING), + ): + _check_windows_path_length() + assert len(caplog.records) == 1 + message = caplog.records[0].getMessage() + assert _LONG_IDF_PATH in message + assert "long path support" in message