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:
parent
6e468e1a30
commit
ac3a8a8e4c
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
Loading…
Reference in New Issue