fix(esp32): core arduino-esp32 headers never resolve to user libs

A user library that ships a core-named header (e.g. WiFiEspAT/src/WiFi.h)
could shadow the arduino-esp32 core during ESP-IDF library resolution.
WiFiEspAT shadowing WiFi.h pulled EspAtDrv.cpp into the build, whose
const char OK[]/STATUS[] collide with ESP-IDF's enum STATUS in
rom/ets_sys.h, breaking every ESP32 sketch that #include <WiFi.h>.

_resolve_library_components now:
- skips a header entirely when the arduino-esp32 core provides it
  (computed set from cores/ + libraries/, cached), so a user lib can
  never shadow WiFi.h/Wire.h/SPI.h/WebServer.h/...
- skips a resolved user lib whose library.properties architectures=
  excludes esp32/*.

Regression: test/backend/unit/test_espidf_core_first.py

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David Montero 2026-06-05 05:11:36 +02:00
parent 6e468e1a30
commit ac3a8a8e4c
3 changed files with 218 additions and 0 deletions

View File

@ -630,6 +630,79 @@ class ESPIDFCompiler:
'WiFiServer.h', 'WiFiType.h', 'esp_wifi.h', 'WiFiServer.h', 'WiFiType.h', 'esp_wifi.h',
}) })
# arduino-esp32 uses a single library architecture id ("esp32") across
# every chip variant (esp32 / esp32c3 / esp32s3 ...). A library whose
# library.properties declares architectures= without "esp32" or "*" is
# built for another platform and must not be pulled into an ESP32 build.
_ESP32_LIB_ARCH = 'esp32'
def _core_provided_headers(self) -> frozenset[str]:
"""Header filenames provided by the arduino-esp32 core itself
(its `cores/` tree plus every bundled library under `libraries/`).
These are compiled into the arduino-esp32 IDF component, so a user
library must NEVER shadow them even when a lib installed in
~/Arduino/libraries happens to ship a file by the same name. The
canonical break this guards against: `WiFiEspAT/src/WiFi.h` (an
ESP8266 AT-modem library) shadowing the core `WiFi.h`, which drags
`EspAtDrv.cpp` into the build where its `const char OK[]` /
`const char STATUS[]` collide with ESP-IDF's
`enum STATUS { ...OK... }` in rom/ets_sys.h and the compile fails.
Computed once from the core tree and cached. Always unions the
static `_CORE_ESP32_HEADERS` fallback so the guard still holds even
when the core path is unknown (e.g. translation-only mode).
"""
cached = getattr(self, '_core_headers_cache', None)
if cached is not None:
return cached
headers: set[str] = set(self._CORE_ESP32_HEADERS)
root = Path(self.arduino_path) if self.arduino_path else None
if root and root.is_dir():
for sub in ('cores', 'libraries'):
base = root / sub
if not base.is_dir():
continue
for pattern in ('*.h', '*.hpp'):
for f in base.rglob(pattern):
headers.add(f.name)
result = frozenset(headers)
self._core_headers_cache = result
logger.info('[espidf] core-provided header set: %d headers', len(result))
return result
@staticmethod
def _parse_library_properties(lib_root: Path) -> dict[str, str]:
"""Best-effort parse of an Arduino library.properties into a dict."""
props: dict[str, str] = {}
try:
text = (lib_root / 'library.properties').read_text(
encoding='utf-8', errors='ignore'
)
except OSError:
return props
for line in text.splitlines():
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
key, _, value = line.partition('=')
props[key.strip().lower()] = value.strip()
return props
def _library_supports_esp32(self, lib_root: Path) -> bool:
"""True if the library may be used on the ESP32 platform.
A missing/empty `architectures` field means "all architectures"
(the Arduino default), so we allow it. Only libraries that
explicitly enumerate architectures WITHOUT esp32/* are rejected
those are written for another platform and would not compile.
"""
arch = self._parse_library_properties(lib_root).get('architectures', '').strip()
if not arch:
return True
arches = {a.strip().lower() for a in arch.split(',') if a.strip()}
return '*' in arches or self._ESP32_LIB_ARCH in arches
def _resolve_library_components( def _resolve_library_components(
self, self,
ext_headers: list[str], ext_headers: list[str],
@ -678,12 +751,39 @@ class ESPIDFCompiler:
continue continue
resolved_headers.add(header) resolved_headers.add(header)
# Core-first. arduino-esp32 core headers (WiFi.h, Wire.h, SPI.h,
# WebServer.h, HTTPClient.h, ...) are compiled into the
# arduino-esp32 component. They must NEVER resolve to a user
# library, even when an installed lib ships a same-named file
# (e.g. WiFiEspAT/src/WiFi.h). Resolving to it would merge that
# foreign library and break the build. Skip resolution entirely
# and let the core provide the header.
if header in self._core_provided_headers():
logger.info(
f'[espidf] <{header}> is provided by the arduino-esp32 core '
f'— never resolving against user libraries'
)
continue
src_root = ( src_root = (
self._find_library_for_header(header, arduino_libs) self._find_library_for_header(header, arduino_libs)
if arduino_libs and arduino_libs.is_dir() if arduino_libs and arduino_libs.is_dir()
else None else None
) )
# Architecture guard. A user lib that resolves the header but
# whose library.properties declares architectures= without
# esp32/* is written for a different platform (AVR-only AT-modem
# shims, etc.) and would not compile against ESP-IDF. Drop it.
if src_root is not None:
_lib_root = src_root.parent if src_root.name == 'src' else src_root
if not self._library_supports_esp32(_lib_root):
logger.warning(
f'[espidf] <{header}> resolved to "{_lib_root.name}" but its '
f'library.properties architectures exclude esp32 — skipping'
)
src_root = None
# Tracks the "resolved to a core lib that's already compiled into # Tracks the "resolved to a core lib that's already compiled into
# the arduino-esp32 component" case, so we don't fall through to # the arduino-esp32 component" case, so we don't fall through to
# the scary "not found — build may fail" warning below for a # the scary "not found — build may fail" warning below for a

View File

@ -262,3 +262,29 @@ python test_espidf_compiler.py
| `frontend/src/utils/compilationLogger.ts` | Ninja `FAILED:` block state machine; classifies lines as `'error'` | | `frontend/src/utils/compilationLogger.ts` | Ninja `FAILED:` block state machine; classifies lines as `'error'` |
| `frontend/src/components/editor/CompilationConsole.tsx` | Auto-switches to Errors filter when new errors arrive | | `frontend/src/components/editor/CompilationConsole.tsx` | Auto-switches to Errors filter when new errors arrive |
| `backend/test_espidf_compiler.py` | 25-test suite covering all library resolution logic | | `backend/test_espidf_compiler.py` | 25-test suite covering all library resolution logic |
---
## Core-first resolution (2026-06 fix)
The "Library Search Order" / `_detect_external_includes` description above
implied core headers like `WiFi.h` / `Wire.h` were skipped. They were not —
`_BUILTIN_HEADERS` never contained them, so a user library that shipped a
same-named header could shadow the arduino-esp32 core. `WiFiEspAT/src/WiFi.h`
(installed via the Library Manager) shadowed the core `WiFi.h`, dragging
`EspAtDrv.cpp` into the build, whose `const char OK[]` / `const char STATUS[]`
collide with ESP-IDF's `enum STATUS { ... OK ... }` in `rom/ets_sys.h`. Result:
every ESP32 sketch that `#include <WiFi.h>` failed to compile.
Fix in `_resolve_library_components`:
1. **Core-first.** Before any user-lib lookup, a header is skipped if it is
provided by the arduino-esp32 core. The set is computed by
`_core_provided_headers()` scanning `$ARDUINO_ESP32_PATH/{cores,libraries}`
(cached), unioned with the static `_CORE_ESP32_HEADERS` fallback. A core
header can never resolve to a user library, regardless of install order.
2. **Architecture guard.** A user lib that resolves a header but whose
`library.properties` `architectures=` excludes `esp32`/`*` is skipped
(`_library_supports_esp32()`).
Regression tests: `test/backend/unit/test_espidf_core_first.py`.

View File

@ -0,0 +1,92 @@
"""Regression tests for ESP32 library-resolution core-first behaviour.
A user library that ships a core-named header (e.g. WiFiEspAT/src/WiFi.h)
must never shadow the arduino-esp32 core. This guards the WiFi.h ->
WiFiEspAT -> EspAtDrv.cpp 'const char OK[]'/'STATUS[]' clash with ESP-IDF's
enum STATUS in rom/ets_sys.h, which broke every ESP32 sketch that
#include <WiFi.h>.
No ESP-IDF toolchain required pure resolution logic.
"""
import sys
import tempfile
import shutil
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'backend'))
from app.services.espidf_compiler import ESPIDFCompiler
def _mk(p: Path, content: str = "x") -> None:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content)
class TestCoreFirstResolution(unittest.TestCase):
def setUp(self) -> None:
self.tmp = Path(tempfile.mkdtemp())
def tearDown(self) -> None:
shutil.rmtree(self.tmp, ignore_errors=True)
def test_core_header_not_resolved_to_user_lib(self):
core = self.tmp / "arduino-esp32"
_mk(core / "cores" / "esp32" / "Arduino.h")
_mk(core / "libraries" / "WiFi" / "src" / "WiFi.h")
ulibs = self.tmp / "Arduino" / "libraries"
_mk(ulibs / "WiFiEspAT" / "library.properties",
"name=WiFiEspAT\narchitectures=*\n")
_mk(ulibs / "WiFiEspAT" / "src" / "WiFi.h")
_mk(ulibs / "WiFiEspAT" / "src" / "utility" / "EspAtDrv.cpp",
"const char OK[];")
# a legit cross-platform lib (no architectures field) must still merge
_mk(ulibs / "DHT_sensor_library" / "DHT.h", "#include <Arduino.h>\n")
_mk(ulibs / "DHT_sensor_library" / "DHT.cpp")
c = ESPIDFCompiler()
c.arduino_path = str(core)
c._core_headers_cache = None
out = self.tmp / "project" / "user_libs"
out.mkdir(parents=True)
names, hdr2comp = c._resolve_library_components(
["WiFi.h", "DHT.h"],
arduino_libs=ulibs, esp32_libs=None,
arduino_comp_name="arduino-esp32", user_libs_dir=out,
)
merged = out / "user_libs_all"
copied = [p.name for p in merged.rglob("*")] if merged.exists() else []
self.assertNotIn("EspAtDrv.cpp", copied,
"WiFiEspAT was merged — WiFi.h shadowed the core")
self.assertNotIn("WiFi.h", hdr2comp)
self.assertIn("DHT.cpp", copied)
self.assertEqual(hdr2comp.get("DHT.h"), "user_libs_all")
def test_arch_excluded_lib_skipped(self):
ulibs = self.tmp / "Arduino" / "libraries"
_mk(ulibs / "AvrOnlyLib" / "library.properties",
"name=AvrOnlyLib\narchitectures=avr\n")
_mk(ulibs / "AvrOnlyLib" / "Foo.h")
_mk(ulibs / "AvrOnlyLib" / "Foo.cpp")
c = ESPIDFCompiler()
c.arduino_path = "" # no core path -> static fallback set
c._core_headers_cache = None
out = self.tmp / "project" / "user_libs"
out.mkdir(parents=True)
names, hdr2comp = c._resolve_library_components(
["Foo.h"],
arduino_libs=ulibs, esp32_libs=None,
arduino_comp_name="arduino-esp32", user_libs_dir=out,
)
self.assertNotIn("Foo.h", hdr2comp)
if __name__ == "__main__":
unittest.main()