fix(esp32): reset build dir on library-set change via options hash, not mid-compile wipe

Supersedes the mid-_compile_in_dir build/ wipe (161deb9): wiping build/ AFTER
materializing libs but right before cmake left ESP-IDF's config half-regenerated
during the fallback's scoped->scan-all double-compile, intermittently failing
with 'sdkconfig.h: No such file'.

Instead fold the effective library set (the manifest, else the sketch's
non-core external includes) into the per-attempt build-dir hash, so a changed
lib set — or the scan-all fallback after a scoped attempt — resets the
persistent build/ at _prepare_persistent_project_dir time (before any cmake).
That is the existing, well-tested early-wipe path, so the configure is always
clean. Same lib set across compiles keeps the warm ccache/ninja cache; core-only
sketches share one dir (core headers filtered out of the token) so they never
trigger a spurious wipe.

Fixes both the original cross-compile staleness (intermittent cmake-configure
failures + stale-object false positives) and the fallback regression.
This commit is contained in:
David Montero 2026-06-06 20:53:18 +02:00
parent 161deb93e2
commit 33fbc03429
1 changed files with 27 additions and 50 deletions

View File

@ -779,24 +779,6 @@ class ESPIDFCompiler:
out[h] = cands
return out
@staticmethod
def _fingerprint_dir(d: Path) -> str:
"""Stable fingerprint of a directory's file set: sorted relative paths
+ sizes. Captures libraries added / removed / version-changed without
reading file contents. Empty / missing dir -> a stable constant."""
h = hashlib.sha256()
if d.is_dir():
for f in sorted(d.rglob('*')):
if f.is_file():
h.update(f.relative_to(d).as_posix().encode())
h.update(b'\0')
try:
h.update(str(f.stat().st_size).encode())
except OSError:
pass
h.update(b'\0')
return h.hexdigest()
def _resolve_library_components(
self,
ext_headers: list[str],
@ -1760,9 +1742,35 @@ class ESPIDFCompiler:
json.dumps(normalized_opts, sort_keys=True).encode()
).hexdigest()[:12]
# External-include set of the sketch — a stable proxy for "which
# libraries this compile resolves". Folded into the per-attempt build
# hash below so the persistent build/ is reset (via the tested
# _prepare_persistent_project_dir wipe) whenever the library set
# changes between compiles. Without this, the shared build/ caches a
# cmake config + ninja graph + ccache objects for the PREVIOUS lib set,
# which causes intermittent "cmake configure failed" and stale-object
# false positives when a different project/manifest compiles next.
_sketch_text = '\n'.join(f.get('content', '') for f in files)
_core_hdrs = self._core_provided_headers()
_ext_inc_token = ','.join(sorted(
h for h in set(self._detect_external_includes(_sketch_text))
if h not in _core_hdrs
))
async def _attempt(allowed: set[str] | None) -> dict:
# Fold the effective library set into the build-dir hash. A
# different manifest, or the scan-all fallback (allowed=None) after
# a scoped attempt, gets its own clean build dir — resetting at
# _prepare time (before any cmake), which is the well-tested wipe
# path. Same lib set across compiles -> same hash -> warm cache.
_libs_token = (
'm:' + ','.join(sorted(allowed)) if allowed is not None else 'scanall'
)
eff_hash = hashlib.sha256(
(options_hash + '|' + _libs_token + '|i:' + _ext_inc_token).encode()
).hexdigest()[:12]
if _USE_PERSISTENT_DIR:
project_dir = _prepare_persistent_project_dir(idf_target, options_hash)
project_dir = _prepare_persistent_project_dir(idf_target, eff_hash)
logger.info(f'[espidf] Using persistent build dir: {project_dir}')
return await self._compile_in_dir(
project_dir, files, idf_target, is_c3,
@ -1963,37 +1971,6 @@ class ESPIDFCompiler:
if main_cpp.exists():
main_cpp.unlink()
# ── Persistent-build-dir staleness guard ─────────────────────────
# The persistent build/ caches ESP-IDF's cmake configuration, ninja's
# incremental graph and (via ccache) compiled objects, all assuming a
# STABLE component set. When the resolved user_libs set changes between
# consecutive compiles on this dir (a different project/user, or a
# different library manifest) that cache is inconsistent: cmake
# reconfigure can fail ("cmake configure failed"), or ninja/ccache can
# reuse a previous compile's objects/headers and let a now-absent
# library slip through — a false-positive success against a lib the
# current sketch/manifest no longer includes. Force a clean configure
# by wiping build/ whenever the user_libs fingerprint changes. ccache
# (enabled) refills the objects so the rebuild stays cheap. No-op on
# the ephemeral path (fresh dir, no build/) and the first compile.
if _USE_PERSISTENT_DIR:
ul_fp = self._fingerprint_dir(project_dir / 'user_libs')
fp_sentinel = project_dir / '.user_libs_fingerprint'
prior_fp = (
fp_sentinel.read_text(encoding='utf-8').strip()
if fp_sentinel.exists() else ''
)
if ul_fp != prior_fp:
_bd = project_dir / 'build'
if _bd.exists():
logger.info(
'[espidf] resolved library set changed (%s -> %s); '
'wiping build/ for a clean configure',
prior_fp[:12] or 'none', ul_fp[:12],
)
shutil.rmtree(_bd, ignore_errors=True)
fp_sentinel.write_text(ul_fp, encoding='utf-8')
# Build using cmake + ninja (more portable than idf.py on Windows)
build_dir = project_dir / 'build'
build_dir.mkdir(exist_ok=True)