[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"
# 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:
"""
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"] = ""

View File

@@ -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