fix(esp32): per-variant persistent build dirs instead of wiping one shared dir

Replaces the wipe-on-change approach (which left ESP-IDF's nested bootloader /
managed-components build in a broken state under rapid reconfigure -
intermittent 'managed_components_list.temp.cmake: No such file'). Each distinct
configuration (board options x resolved library set, via the variant key the
caller already computes) now gets its OWN persistent project dir with its OWN
build/, never wiped or reconfigured for a different config:

  - same config  -> same dir -> warm ninja incremental + ccache (fast iterate);
  - different config -> different dir -> isolated, consistent, no staleness,
    no nested-build breakage;
  - the scoped vs scan-all fallback attempts land in different dirs, so the
    double-compile no longer corrupts a shared build/.

Variant dirs are LRU-bounded (_MAX_BUILD_VARIANTS per target); the global ccache
warms a fresh/evicted variant in seconds. Cleans up the old single-project
layout on first run. Fixes the cross-compile staleness for legacy AND manifest
compiles, and the fallback regression.
This commit is contained in:
David Montero 2026-06-06 21:13:42 +02:00
parent 33fbc03429
commit c996ca3717
1 changed files with 60 additions and 50 deletions

View File

@ -169,78 +169,88 @@ def _run_with_streaming(
)
# How many distinct build variants to keep per target before LRU-evicting the
# coldest. Each variant is a full ESP-IDF build tree (~240 MB). Distinct
# variants = distinct (board options x resolved library set). The global ccache
# means an evicted-then-rebuilt variant warms up in seconds.
_MAX_BUILD_VARIANTS = 12
def _evict_cold_variants(target_dir: Path, keep: int) -> None:
"""LRU-evict variant dirs (``v_*``) beyond ``keep``, coldest mtime first."""
try:
variants = [
d for d in target_dir.iterdir()
if d.is_dir() and d.name.startswith('v_')
]
except OSError:
return
if len(variants) <= keep:
return
variants.sort(key=lambda d: d.stat().st_mtime if d.exists() else 0.0)
for d in variants[:len(variants) - keep]:
logger.info(f'[espidf] LRU-evicting cold build variant {d.name}')
shutil.rmtree(d, ignore_errors=True)
def _prepare_persistent_project_dir(
idf_target: str,
options_hash: str = '',
variant_key: str = 'default',
) -> Path:
"""Return the path to a per-target persistent project dir, materialising
it from the template on first use and resetting the per-compile parts
(main/, user_libs/) on every call so a previous sketch's files don't
leak into the next compile.
"""Return a persistent project dir for (idf_target, variant_key), created
from the template on first use and with the per-compile parts (main/,
user_libs/) reset each call so a previous sketch doesn't leak into the next.
Keeps the `build/` directory intact across calls that's where ninja's
incremental cache + the .o files we want ccache to hit live.
`options_hash` (12-char prefix of sha256 over normalised board options)
is stored in a second sentinel `.options_hash`. When the hash changes
between compiles, build/ is wiped so cached .o files compiled against
the previous sdkconfig don't poison the new config. The wider target
dir is preserved (template files, idf version sentinel) so we only pay
the C/C++ recompile cost, not the template re-copy.
Each distinct ``variant_key`` gets its OWN dir with its OWN ``build/`` that
is NEVER wiped/reconfigured for a different config. The caller folds the
board options AND the resolved library set into the key, so two compiles
that would produce a different ESP-IDF component graph never share a build/.
Sharing one build/ across configs caused intermittent cmake-configure
failures, stale-object false positives, and nested-build breakage (a wiped+
reconfigured dir loses ESP-IDF managed-component temp files). Same variant
-> same dir -> warm ninja incremental + ccache. Cold variant -> fresh build,
fast via the global ccache. The variant set is LRU-bounded per target.
"""
target_dir = _BUILD_ROOT / idf_target
target_dir.mkdir(parents=True, exist_ok=True)
# Wipe the whole target dir if the toolchain version changed (the cached
# .o files are no longer compatible).
# Wipe ALL variants for this target if the toolchain changed (cached .o
# files are no longer ABI-compatible).
sentinel = target_dir / '.idf_version'
current_signature = _idf_version_signature()
if sentinel.exists() and sentinel.read_text(encoding='utf-8').strip() != current_signature:
logger.info(
f'[espidf] toolchain version changed; wiping persistent build dir {target_dir}'
)
shutil.rmtree(target_dir)
logger.info(f'[espidf] toolchain version changed; wiping {target_dir}')
shutil.rmtree(target_dir, ignore_errors=True)
target_dir.mkdir(parents=True, exist_ok=True)
sentinel.write_text(current_signature, encoding='utf-8')
project_dir = target_dir / 'project'
options_sentinel = target_dir / '.options_hash'
prior_options_hash = (
options_sentinel.read_text(encoding='utf-8').strip()
if options_sentinel.exists() else ''
)
# One-time cleanup of the pre-variant layout (target_dir/project) so it
# doesn't orphan ~240 MB after the upgrade to per-variant dirs.
legacy_project = target_dir / 'project'
if legacy_project.exists():
shutil.rmtree(legacy_project, ignore_errors=True)
(target_dir / '.options_hash').unlink(missing_ok=True)
safe = ''.join(c for c in variant_key if c.isalnum() or c in '-_')[:40] or 'default'
variant_dir = target_dir / ('v_' + safe)
project_dir = variant_dir / 'project'
if not project_dir.exists():
# First use of this target — full template copy.
variant_dir.mkdir(parents=True, exist_ok=True)
shutil.copytree(_TEMPLATE_DIR, project_dir)
else:
# Subsequent compile — reset the per-compile parts only, keep build/.
# main/ is overwritten with the user's sketch + any extra files.
# user_libs/ is rebuilt by _resolve_library_components based on the
# sketch's #includes.
# Reset per-compile parts only; keep build/ (warm for this variant).
shutil.rmtree(project_dir / 'main', ignore_errors=True)
shutil.copytree(_TEMPLATE_DIR / 'main', project_dir / 'main')
shutil.rmtree(project_dir / 'user_libs', ignore_errors=True)
if options_hash and options_hash != prior_options_hash:
# First compile after this feature shipped: prior_options_hash is
# empty but the persistent build/ was compiled against the old
# static sdkconfig. Wipe it once to force a clean reconfigure
# against the templated config — otherwise stale .o files
# silently mask the new options.
reason = (
'first compile post-upgrade'
if not prior_options_hash
else f'changed {prior_options_hash!r} -> {options_hash!r}'
)
logger.info(
f'[espidf] board options {reason}; '
f'wiping build/ to force a full reconfigure'
)
shutil.rmtree(project_dir / 'build', ignore_errors=True)
# Mark this variant most-recently-used, then LRU-evict the coldest.
try:
os.utime(variant_dir, None)
except OSError:
pass
_evict_cold_variants(target_dir, keep=_MAX_BUILD_VARIANTS)
sentinel.write_text(current_signature, encoding='utf-8')
if options_hash:
options_sentinel.write_text(options_hash, encoding='utf-8')
return project_dir
# Static IP that matches slirp DHCP range (first client = x.x.x.15)