[espidf] Warn when the install path is too long for Windows MAX_PATH (#16896)

This commit is contained in:
Jonathan Swoboda
2026-06-09 21:12:28 -04:00
committed by GitHub
parent e16a877745
commit 6809af3de0
2 changed files with 189 additions and 0 deletions

View File

@@ -85,6 +85,75 @@ def _get_idf_tools_path() -> Path:
return CORE.data_dir / "idf" 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/<target>/<ver>/../../../../<target>/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: def _get_framework_path(version: str) -> Path:
""" """
Get the path to the ESPHome ESP-IDF framework directory for a specific version. Get the path to the ESPHome ESP-IDF framework directory for a specific version.
@@ -705,6 +774,8 @@ def check_esp_idf_install(
Returns: Returns:
tuple of (framework_path, python_env_path) tuple of (framework_path, python_env_path)
""" """
_check_windows_path_length()
env = {} env = {}
env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path()) env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path())
env["IDF_PATH"] = "" env["IDF_PATH"] = ""

View File

@@ -2,9 +2,12 @@
# pylint: disable=protected-access # pylint: disable=protected-access
from contextlib import contextmanager
import io import io
import json import json
import logging
from pathlib import Path from pathlib import Path
import sys
import tarfile import tarfile
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
@@ -13,6 +16,7 @@ import pytest
from esphome.espidf.framework import ( from esphome.espidf.framework import (
_check_stamp, _check_stamp,
_check_windows_path_length,
_clone_idf_with_submodules, _clone_idf_with_submodules,
_get_framework_path, _get_framework_path,
_get_idf_tool_paths, _get_idf_tool_paths,
@@ -22,6 +26,7 @@ from esphome.espidf.framework import (
_get_python_version, _get_python_version,
_parse_git_source, _parse_git_source,
_patch_tools_json_for_linux_arm64, _patch_tools_json_for_linux_arm64,
_windows_long_paths_enabled,
_write_idf_version_txt, _write_idf_version_txt,
_write_stamp, _write_stamp,
check_esp_idf_install, 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")): with patch("pathlib.Path.write_text", side_effect=OSError("denied")):
# write failure is caught and warned, not raised # write failure is caught and warned, not raised
_write_idf_version_txt(tmp_path, "5.1.2") _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