Compare commits

...
Author SHA1 Message Date
J. Nick Koston e4570b783d Merge branch 'esp32-pio-pch' into nrf52-pch 2026-09-03 23:48:54 +02:00
J. Nick Koston d6446fdb5f Merge branch 'host-pch' into esp32-pio-pch 2026-09-03 23:48:53 +02:00
J. Nick Koston bc1c91a5f7 Merge branch 'pch-strict-ci' into host-pch 2026-09-03 23:48:53 +02:00
J. Nick Koston e2229f1a5e Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-09-03 23:48:53 +02:00
J. Nick Koston f99b95e9a2 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-09-03 23:48:53 +02:00
J. Nick Koston 2b7f5f0ea9 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-09-03 23:48:52 +02:00
J. Nick Koston f5859f020b Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-09-03 23:48:52 +02:00
J. Nick Koston b027eb5cce Merge branch 'esp32-pio-pch' into nrf52-pch 2026-09-02 11:18:10 +02:00
J. Nick Koston a5701c43ba Merge branch 'host-pch' into esp32-pio-pch 2026-09-02 11:18:09 +02:00
J. Nick Koston b81d250737 Merge branch 'pch-strict-ci' into host-pch 2026-09-02 11:18:09 +02:00
J. Nick Koston 85b03d9dc1 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-09-02 11:18:09 +02:00
J. Nick Koston 8639526e5f Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-09-02 11:18:08 +02:00
J. Nick Koston 95fd6c42f3 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-09-02 11:18:08 +02:00
J. Nick Koston 42761a66b4 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-09-02 11:18:07 +02:00
J. Nick Koston f1f28c6969 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-30 14:57:41 -05:00
J. Nick Koston 6d1d439d7b Merge branch 'host-pch' into esp32-pio-pch 2026-08-30 14:57:41 -05:00
J. Nick Koston cdd89dbfed Merge branch 'pch-strict-ci' into host-pch 2026-08-30 14:57:41 -05:00
J. Nick Koston 59367c9c44 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-30 14:57:40 -05:00
J. Nick Koston 2a41f7e2a8 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-30 14:57:40 -05:00
J. Nick Koston 9ef69d1576 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-30 14:57:40 -05:00
J. Nick Koston 4a7bd2d2e9 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-30 14:57:40 -05:00
J. Nick Koston 4be9081349 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-28 20:52:40 -05:00
J. Nick Koston de7f9e3d93 Merge branch 'host-pch' into esp32-pio-pch 2026-08-28 20:52:40 -05:00
J. Nick Koston 150f0a3b6c Merge branch 'pch-strict-ci' into host-pch 2026-08-28 20:52:39 -05:00
J. Nick Koston cf8872344b Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-28 20:52:39 -05:00
J. Nick Koston 6dc37ffe86 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-28 20:52:39 -05:00
J. Nick Koston f528758c8a Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-28 20:52:39 -05:00
J. Nick Koston d1704cabb2 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-28 20:52:39 -05:00
J. Nick Koston e3946c1871 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-28 20:45:04 -05:00
J. Nick Koston 6a6a6679bc Merge branch 'host-pch' into esp32-pio-pch 2026-08-28 20:45:04 -05:00
J. Nick Koston 4af285a5a0 Merge branch 'pch-strict-ci' into host-pch 2026-08-28 20:45:04 -05:00
J. Nick Koston e43d2f71f4 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-28 20:45:04 -05:00
J. Nick Koston 741e436d2c Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-28 20:45:04 -05:00
J. Nick Koston 34c54e4dce Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-28 20:45:03 -05:00
J. Nick Koston 0d75e3e028 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-28 20:45:03 -05:00
J. Nick Koston 9c8e560b7f Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-28 20:41:33 -05:00
J. Nick Koston 0b0f86527d Merge branch 'host-pch' into esp32-pio-pch 2026-08-28 20:41:33 -05:00
J. Nick Koston 168d96d9f4 Merge branch 'pch-strict-ci' into host-pch 2026-08-28 20:41:32 -05:00
J. Nick Koston e6dfdc8009 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-28 20:41:32 -05:00
J. Nick Koston 4b43c026f0 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-28 20:41:32 -05:00
J. Nick Koston 0f6309bc92 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-28 20:41:32 -05:00
J. Nick Koston d1b28124bc Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-28 20:41:31 -05:00
J. Nick Koston a2f791e846 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-28 13:52:02 -05:00
J. Nick Koston 9b1223b700 Merge branch 'host-pch' into esp32-pio-pch 2026-08-28 13:52:01 -05:00
J. Nick Koston 94fc9ec63a Merge branch 'pch-strict-ci' into host-pch 2026-08-28 13:52:01 -05:00
J. Nick Koston 340ac06699 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-28 13:52:01 -05:00
J. Nick Koston 455b14e10f Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-28 13:52:01 -05:00
J. Nick Koston 9e995377d5 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-28 13:52:01 -05:00
J. Nick Koston ade078a7f6 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-28 13:52:00 -05:00
J. Nick Koston 7ae325500e Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-28 13:07:07 -05:00
J. Nick Koston 5c970df827 Merge branch 'host-pch' into esp32-pio-pch 2026-08-28 13:07:07 -05:00
J. Nick Koston e51f711883 Merge branch 'pch-strict-ci' into host-pch 2026-08-28 13:07:06 -05:00
J. Nick Koston ece74ee0d1 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-28 13:07:06 -05:00
J. Nick Koston 7a947cd4c9 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-28 13:07:06 -05:00
J. Nick Koston 40f548a529 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-28 13:07:06 -05:00
J. Nick Koston 7e20e497bd Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-28 13:07:05 -05:00
J. Nick Koston 33885bfddf Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-27 23:01:19 -05:00
J. Nick Koston 853ef14f10 Merge branch 'host-pch' into esp32-pio-pch 2026-08-27 23:01:19 -05:00
J. Nick Koston addcb472af Merge branch 'pch-strict-ci' into host-pch 2026-08-27 23:01:19 -05:00
J. Nick Koston 4ecbde8bbc Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-27 23:01:19 -05:00
J. Nick Koston 9794eb9fc2 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-27 23:01:18 -05:00
J. Nick Koston fc41843cd5 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-27 23:01:18 -05:00
J. Nick Koston 0d50b57414 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-27 23:01:18 -05:00
J. Nick Koston 1f77384389 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-27 15:53:00 -05:00
J. Nick Koston b97ba73757 Merge branch 'host-pch' into esp32-pio-pch 2026-08-27 15:53:00 -05:00
J. Nick Koston b5c78a4711 Merge branch 'pch-strict-ci' into host-pch 2026-08-27 15:53:00 -05:00
J. Nick Koston a114147e03 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-27 15:53:00 -05:00
J. Nick Koston 80b592fa16 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-27 15:52:59 -05:00
J. Nick Koston 665f1d46ca Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-27 15:52:59 -05:00
J. Nick Koston 5f97c5756b Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-27 15:52:59 -05:00
J. Nick Koston 02761d72ab Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-27 14:04:03 -05:00
J. Nick Koston 5f9af0cfa5 Merge branch 'host-pch' into esp32-pio-pch 2026-08-27 14:04:03 -05:00
J. Nick Koston 5bc3102c82 Merge branch 'pch-strict-ci' into host-pch 2026-08-27 14:04:02 -05:00
J. Nick Koston a5e4882196 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-27 14:04:02 -05:00
J. Nick Koston 4f9a02f85a Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-27 14:04:02 -05:00
J. Nick Koston 8f1e581bf6 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-27 14:04:02 -05:00
J. Nick Koston 8f8e6e95d4 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-27 14:04:02 -05:00
J. Nick Koston 52879b8a7f Skip the doomed gch compile after header-generation failure, self-heal the placeholder from CMake 2026-08-27 13:43:36 -05:00
J. Nick Koston 330adb9ba4 Cover the placeholder-touch failure path 2026-08-27 12:53:06 -05:00
J. Nick Koston e3c1d51f4f Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-27 12:46:19 -05:00
J. Nick Koston 640f2de767 Merge user platformio option strings into list options, scope the esp32 PlatformIO trigger to the pch modules 2026-08-27 12:45:59 -05:00
J. Nick Koston f180c95a65 Merge remote-tracking branch 'origin/nrf52-pch' into nrf52-pch 2026-08-27 12:42:50 -05:00
J. Nick Koston aa25b5fb0b Annotate the compile_ctx fixture 2026-08-27 12:42:36 -05:00
J. Nick Koston c8b54a33a9 Merge remote-tracking branch 'origin/nrf52-pch' into nrf52-pch 2026-08-27 12:39:40 -05:00
J. Nick Koston 17d1874ca3 Degrade on header-generation failure, surface probe and touch errors 2026-08-27 12:38:42 -05:00
J. Nick Koston c4c6fe198e Merge branch 'pch-strict-ci' into host-pch 2026-08-27 12:38:39 -05:00
J. Nick Koston 7850fba837 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-27 12:38:39 -05:00
J. Nick Koston f66eb89f28 Merge branch 'host-pch' into esp32-pio-pch 2026-08-27 12:38:39 -05:00
J. Nick Koston 168c772e79 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-27 12:38:39 -05:00
J. Nick Koston c2eb877a17 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-27 12:38:32 -05:00
J. Nick Koston 72c670f5f1 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-27 12:38:31 -05:00
J. Nick Koston ecc9248426 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-27 12:38:31 -05:00
J. Nick Koston e56edc4af0 Drop the relative-path branch; compile databases record absolute paths 2026-08-27 12:37:01 -05:00
J. Nick Koston c6b1a41e76 Match the pch source TU by resolved path so symlinked build dirs keep the pch 2026-08-27 12:31:53 -05:00
J. Nick Koston a638ac3982 Trim pch comments to repo standard, use partial for the prepare thunk 2026-08-27 11:05:10 -05:00
J. Nick Koston d45e0074a3 Share the guarded pch prepare contract, simplify wiring and tests 2026-08-27 11:01:46 -05:00
J. Nick Koston cf084bc70f Share the CMake pch consumer block, apply review suggestions 2026-08-27 10:53:26 -05:00
J. Nick Koston 21c1c582b5 Generate the Zephyr syscall headers before compiling the pch 2026-08-27 10:44:38 -05:00
J. Nick Koston 8defc48611 Resolve the sysbuild app domain dir for the pch 2026-08-27 10:34:17 -05:00
J. Nick Koston 10ea99d4c9 Add precompiled header support to the nRF52 sdk-nrf build 2026-08-27 10:25:45 -05:00
J. Nick Koston a4136c8a17 Run copy_files to completion in the gate test, pin the build_helpers trigger 2026-08-27 10:11:55 -05:00
J. Nick Koston de0f706da4 Merge branch 'host-pch' into esp32-pio-pch 2026-08-27 09:41:01 -05:00
J. Nick Koston 41d68d2978 Merge branch 'pch-strict-ci' into host-pch 2026-08-27 09:40:58 -05:00
J. Nick Koston 59556a730d Keep the parity test hermetic, give touch the shared error contract 2026-08-27 09:40:54 -05:00
J. Nick Koston b367238273 Adopt the shared prefix constant everywhere, trigger CI on build_helpers, pin the copy_files gate 2026-08-27 09:31:31 -05:00
J. Nick Koston 5ab690cca6 Merge branch 'host-pch' into esp32-pio-pch 2026-08-27 09:13:57 -05:00
J. Nick Koston 7d4e28b5e2 Merge branch 'pch-strict-ci' into host-pch 2026-08-27 09:13:55 -05:00
J. Nick Koston 87cbe42cae Blame the baseline honestly, split sidecar unlinks, pin the script's knob and import branches 2026-08-27 09:13:51 -05:00
J. Nick Koston b4d6633b19 Enable the pch on esp32 via PlatformIO for CI speed 2026-08-27 08:47:05 -05:00
J. Nick Koston 8180134d30 Merge branch 'pch-strict-ci' into host-pch 2026-08-27 08:40:39 -05:00
J. Nick Koston 8a5d53899c Simplify: strict knob via parse_enable_env, one probe tail, latch folded into _fail, discard owns its policy 2026-08-27 08:40:35 -05:00
J. Nick Koston 92b71f8ce4 Exclude the prefix wrapper from esphome.h, state the failure-path cost honestly 2026-08-27 08:33:25 -05:00
J. Nick Koston 4605ac08a5 Merge branch 'pch-strict-ci' into host-pch 2026-08-27 08:32:13 -05:00
J. Nick Koston 3e5688d6cd Blame the pch only when a baseline passes, validate the strict knob, abort on an undiscardable stale header 2026-08-27 08:32:01 -05:00
J. Nick Koston 9580fd14da Merge branch 'pch-strict-ci' into host-pch 2026-08-27 00:40:42 -05:00
J. Nick Koston d38542e48b Escalate all strict consumers, keep cached-probe rejections unlatched, drop the dead probe knob 2026-08-27 00:40:37 -05:00
J. Nick Koston 364c530832 Merge branch 'pch-strict-ci' into host-pch 2026-08-27 00:26:00 -05:00
J. Nick Koston c89c2d975d Strict escalates consumer invalid-pch, re-probes cached headers everywhere, and defaults the matrix to strict 2026-08-27 00:25:38 -05:00
J. Nick Koston a0c3a010e4 Guard the prefix for C edges, gate it on the pch knob, pin its resolution 2026-08-27 00:21:14 -05:00
J. Nick Koston fd871b8898 Precompile a curated core-header prefix on host 2026-08-27 00:10:07 -05:00
J. Nick Koston 78eff4f485 Strict re-probes a cached .gch, probe diagnostics get the exit-code fallback, nobuild detection narrows to ImportError 2026-08-26 23:43:09 -05:00
J. Nick Koston 833af674bf Simplify: shared tool-step runner, probe args helper, buildtool touch stamp, knob-spelling parity 2026-08-26 23:20:06 -05:00
J. Nick Koston 15d4f187c7 Bake the probe stamp into the rule text; edge bindings cannot expand $out 2026-08-26 23:10:55 -05:00
J. Nick Koston 21643396c4 Add an esp8266 native-toolchain smoke entry to the strict compile matrix 2026-08-26 23:03:00 -05:00
J. Nick Koston 34daa6138a Probe failures follow the compile branch's transient rules, strict on the native esp8266 job 2026-08-26 23:02:18 -05:00
J. Nick Koston 0266b7f0fb Cover the probe spawn failure and both probe-edge flag branches 2026-08-26 22:55:52 -05:00
J. Nick Koston 5d690104b5 Strict fails on a disabled pch, narrows the projenv exemption, and covers the new funnel sites 2026-08-26 22:51:53 -05:00
J. Nick Koston 4a3b095172 Probe the built .gch on the native paths so strict CI reds on an unloadable pch 2026-08-26 22:47:42 -05:00
J. Nick Koston 54bff55642 Reraise in strict through the IDF flow, funnel the remaining degrade paths, exempt nobuild 2026-08-26 22:42:03 -05:00
J. Nick Koston 264ccc0989 Add ESPHOME_PCH_STRICT and enforce it in the esp8266/esp32/rp2 image compile tests 2026-08-26 22:29:53 -05:00
J. Nick Koston 7449623022 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 22:23:01 -05:00
J. Nick Koston 2eab26052a Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 22:22:58 -05:00
J. Nick Koston 96beeed2c2 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 22:22:55 -05:00
J. Nick Koston 1e13390b6d Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 22:06:04 -05:00
J. Nick Koston c916e93988 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 22:06:04 -05:00
J. Nick Koston 3ecd4e3e69 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 22:06:04 -05:00
J. Nick Koston 3e3655036b Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 14:23:26 -05:00
J. Nick Koston 58d6aec7fd Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 14:23:23 -05:00
J. Nick Koston c97250508a Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 14:23:20 -05:00
J. Nick Koston 16e4e05d92 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 14:14:08 -05:00
J. Nick Koston 153bfbc47c Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 14:14:05 -05:00
J. Nick Koston 50c9a3a5bb Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 14:14:02 -05:00
J. Nick Koston c5d14b9423 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 13:21:30 -05:00
J. Nick Koston 694741cd6b Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 13:21:28 -05:00
J. Nick Koston 1719e75e57 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 13:21:25 -05:00
J. Nick Koston c2d1b1c583 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 13:14:22 -05:00
J. Nick Koston 198e4abc5d Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 13:14:17 -05:00
J. Nick Koston 35ab90fc58 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 13:14:11 -05:00
J. Nick Koston 77fc8af9f0 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 11:59:22 -05:00
J. Nick Koston b8675a4b56 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 11:59:18 -05:00
J. Nick Koston b497ff17a7 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 11:58:36 -05:00
J. Nick Koston f4199a7510 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 10:44:37 -05:00
J. Nick Koston c7f0e17409 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 10:44:35 -05:00
J. Nick Koston 0ba8e3d83e Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 10:44:32 -05:00
J. Nick Koston c6d056b240 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 10:03:33 -05:00
J. Nick Koston ca81777eba Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 10:03:31 -05:00
J. Nick Koston 68f2dc3219 Gate the ccache sloppiness on emission for the IDF backend too 2026-08-26 10:03:26 -05:00
J. Nick Koston c7fb26a6d1 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 10:01:27 -05:00
J. Nick Koston f665ecf977 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 09:58:06 -05:00
J. Nick Koston 5e95306bcc Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 09:58:02 -05:00
J. Nick Koston c813fe8e35 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 09:57:59 -05:00
J. Nick Koston c025b875d1 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 09:34:43 -05:00
J. Nick Koston 6a5a092f03 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 09:34:41 -05:00
J. Nick Koston c10fb88430 Trim comments to repo standard 2026-08-26 09:34:37 -05:00
J. Nick Koston 5be8ad98e8 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 09:33:15 -05:00
J. Nick Koston 639e1c2fdf Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 09:08:08 -05:00
J. Nick Koston 6722ec22ab Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 09:08:05 -05:00
J. Nick Koston 3d35a111ac Pin the compile locale in the generic pch flow too 2026-08-26 09:08:02 -05:00
J. Nick Koston a07bdf3a03 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 09:07:56 -05:00
J. Nick Koston e2446cad8f Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 08:40:41 -05:00
J. Nick Koston 404cf7b728 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 08:40:38 -05:00
J. Nick Koston 987da8f5c4 Treat resource exhaustion as transient in the generic pch flow too 2026-08-26 08:40:35 -05:00
J. Nick Koston c9963881ba Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 08:39:47 -05:00
J. Nick Koston 2a7245ac79 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 00:46:51 -05:00
J. Nick Koston 1ca8b7066c Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 00:46:48 -05:00
J. Nick Koston e13aa8b775 Read the pch sidecars defensively in the generic flow too 2026-08-26 00:46:45 -05:00
J. Nick Koston 3a483e3284 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 00:46:31 -05:00
J. Nick Koston 0fea10f263 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 00:06:44 -05:00
J. Nick Koston 187199e9b3 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 00:06:43 -05:00
J. Nick Koston fb5a042dfe Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 00:06:43 -05:00
J. Nick Koston a892f47f89 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 23:42:31 -05:00
J. Nick Koston 9fa031f134 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 23:42:31 -05:00
J. Nick Koston b255e77e76 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 23:42:30 -05:00
J. Nick Koston bffbc6dce5 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 23:16:53 -05:00
J. Nick Koston ba9131324a Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 23:16:53 -05:00
J. Nick Koston 5ab40084c9 Merge remote-tracking branch 'origin/platformio-pch-libretiny' into platformio-pch-libretiny 2026-08-25 23:16:53 -05:00
J. Nick Koston 82b9575b6f Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 23:16:52 -05:00
J. Nick Koston 6223ecda32 Merge remote-tracking branch 'origin/platformio-pch-rp2' into platformio-pch-rp2 2026-08-25 23:16:52 -05:00
J. Nick Koston 5bd7884ac1 Merge remote-tracking branch 'origin/esp32-idf-pch' into esp32-idf-pch 2026-08-25 23:16:52 -05:00
J. Nick Koston c1c78ebf18 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 23:16:33 -05:00
J. Nick Koston dd05cdd2f8 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 23:16:33 -05:00
J. Nick Koston cfcbf5b420 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 23:16:33 -05:00
J. Nick Koston 80206fd232 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 23:03:16 -05:00
J. Nick Koston e18bcc74f3 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 23:03:13 -05:00
J. Nick Koston 40f74815f0 Widen the identity-unknown guard to UnicodeError in the generic prepare_pch 2026-08-25 23:03:11 -05:00
J. Nick Koston eb28f2630a Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 23:02:56 -05:00
J. Nick Koston 5b53eb9f6c Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 22:55:24 -05:00
J. Nick Koston 0768a1c8cf Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 22:55:24 -05:00
J. Nick Koston 32c1125116 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 22:55:24 -05:00
J. Nick Koston 5cd9e77f3d Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 22:53:03 -05:00
J. Nick Koston 5a05184ee3 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 22:53:03 -05:00
J. Nick Koston a9284c7d4b Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 22:53:03 -05:00
J. Nick Koston 53a3501bdc Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 22:40:46 -05:00
J. Nick Koston bcdefa873b Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 22:40:46 -05:00
J. Nick Koston 6c86c02769 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 22:40:46 -05:00
J. Nick Koston 9304661da3 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 22:39:45 -05:00
J. Nick Koston fe95bd2d0c Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 22:39:45 -05:00
J. Nick Koston 2a45a21ad3 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 22:39:45 -05:00
J. Nick Koston 85d49bd940 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 22:29:40 -05:00
J. Nick Koston 36062184a1 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 22:29:40 -05:00
J. Nick Koston 2864ea8c9a Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 22:29:40 -05:00
J. Nick Koston 5f81a04086 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 22:23:08 -05:00
J. Nick Koston ac176636db Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 22:23:08 -05:00
J. Nick Koston aebcc71201 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 22:23:07 -05:00
J. Nick Koston 68ba1b9a7f Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 22:04:06 -05:00
J. Nick Koston 63e6f9bf70 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 22:04:06 -05:00
J. Nick Koston daccadca3f Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 22:04:06 -05:00
J. Nick Koston f4a5671445 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 21:45:12 -05:00
J. Nick Koston 2beabb2ca3 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 21:45:09 -05:00
J. Nick Koston d2c2414425 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 21:45:06 -05:00
J. Nick Koston c6f21e2447 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 21:33:57 -05:00
J. Nick Koston 9be7585000 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 21:33:56 -05:00
J. Nick Koston b253f32788 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 21:33:56 -05:00
J. Nick Koston 80c9ccd657 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 20:56:19 -05:00
J. Nick Koston c405d94f45 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 20:56:16 -05:00
J. Nick Koston f4003969ba Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 20:56:13 -05:00
J. Nick Koston 0194e2349c Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 20:48:39 -05:00
J. Nick Koston cf2ddde04c Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 20:48:37 -05:00
J. Nick Koston 365dcba506 Skip compile-DB entries with null fields instead of raising 2026-08-25 20:48:28 -05:00
J. Nick Koston 565ffad6f4 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 20:43:51 -05:00
J. Nick Koston 2145f50bb5 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 20:43:48 -05:00
J. Nick Koston a7e4cf536b Drop duplicate ccache test, keep full compiler output at debug, guard empty compile-DB directory 2026-08-25 20:43:37 -05:00
J. Nick Koston 62f8451831 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 20:40:38 -05:00
J. Nick Koston 120ac5e516 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 20:40:35 -05:00
J. Nick Koston eaa63e2695 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 20:40:32 -05:00
J. Nick Koston 259824d13d Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 20:26:29 -05:00
J. Nick Koston daf273b650 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 20:26:29 -05:00
J. Nick Koston faa93cc6e9 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 20:26:29 -05:00
J. Nick Koston 90c804886d Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 20:00:39 -05:00
J. Nick Koston e6e738775b Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 20:00:37 -05:00
J. Nick Koston 791020f778 Merge follow-through: discard the pch when its identity cannot be established 2026-08-25 20:00:27 -05:00
J. Nick Koston 0c5cad19dc Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 19:59:34 -05:00
J. Nick Koston c15bafacfb Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 19:24:00 -05:00
J. Nick Koston 7bd2dd944c Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 19:23:58 -05:00
J. Nick Koston 84028e787a Log the pch line only when true, fail closed on unreadable sdkconfig, treat signal-killed compiles as transient 2026-08-25 19:23:49 -05:00
J. Nick Koston 5d745f0605 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 19:08:39 -05:00
J. Nick Koston 5672302b01 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 19:08:37 -05:00
J. Nick Koston 9faddeb0bc Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 19:08:34 -05:00
J. Nick Koston e25ffaf017 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 18:55:03 -05:00
J. Nick Koston 80b62ceacc Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 18:55:00 -05:00
J. Nick Koston 81bca96e1b Run the gch compile from its resolution dir, self-clean when disabled, log the opt-out hint, pin the knob in tests 2026-08-25 18:54:48 -05:00
J. Nick Koston 256457384c Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 18:20:37 -05:00
J. Nick Koston d5609f8dc6 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 18:20:36 -05:00
J. Nick Koston a4dd668e69 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 18:20:36 -05:00
J. Nick Koston d08bb3992a Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 18:19:27 -05:00
J. Nick Koston 8197fcd5a1 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 18:19:27 -05:00
J. Nick Koston 85520f520e Merge remote-tracking branch 'origin/platformio-pch-libretiny' into platformio-pch-libretiny 2026-08-25 18:19:27 -05:00
J. Nick Koston 2dd850b990 Merge remote-tracking branch 'origin/platformio-pch-rp2' into platformio-pch-rp2 2026-08-25 18:19:27 -05:00
J. Nick Koston d0bbd40e3b Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 18:19:26 -05:00
J. Nick Koston 7c9e7a807d Merge remote-tracking branch 'origin/esp32-idf-pch' into esp32-idf-pch 2026-08-25 18:19:26 -05:00
J. Nick Koston b11b60dd86 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 18:18:24 -05:00
J. Nick Koston efeb608725 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 18:18:24 -05:00
J. Nick Koston bbd3e60d5b Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 18:18:23 -05:00
J. Nick Koston 600dab7185 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 18:13:33 -05:00
J. Nick Koston 609d9dfab3 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 18:13:31 -05:00
J. Nick Koston 8118575491 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 18:13:29 -05:00
J. Nick Koston a28086547f Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 18:05:34 -05:00
J. Nick Koston 0cdd654cc6 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 18:05:31 -05:00
J. Nick Koston 3e2dc30c06 Move the curated header list to build_helpers as PCH_DEFAULT_HEADERS 2026-08-25 18:05:23 -05:00
J. Nick Koston a400753d96 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 18:03:02 -05:00
J. Nick Koston 0f5050a617 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 18:03:00 -05:00
J. Nick Koston 7387211cf1 Move the generic pch build machinery into build_helpers/pch.py 2026-08-25 18:02:51 -05:00
J. Nick Koston 4819f986bc Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 17:58:31 -05:00
J. Nick Koston 904083d531 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 17:58:29 -05:00
J. Nick Koston 2a41892559 Simplify: reuse discard_pch, effective_ccache_basedir, and idedata's C++ suffixes 2026-08-25 17:58:05 -05:00
J. Nick Koston e1eff7447d Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 17:58:05 -05:00
J. Nick Koston d8255cbece Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 17:48:14 -05:00
J. Nick Koston 6175ff24fe Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 17:48:12 -05:00
J. Nick Koston 1059ecf50c Fold header order into pch checksum, gate rebuild-forcing touch, guard malformed compile DBs 2026-08-25 17:48:01 -05:00
J. Nick Koston c5c7b598c9 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 17:45:02 -05:00
J. Nick Koston 10a81076b2 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 16:20:46 -05:00
J. Nick Koston 9a2644e6e6 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 16:20:43 -05:00
J. Nick Koston 743f1dcb09 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 16:20:06 -05:00
J. Nick Koston ceac527259 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 15:54:05 -05:00
J. Nick Koston 0908d1d3c6 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 15:54:02 -05:00
J. Nick Koston 278abf2771 Treat pch spawn errors as transient, keep user force-includes, fold command into checksum 2026-08-25 15:53:54 -05:00
J. Nick Koston 408dcd353a Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 15:44:03 -05:00
J. Nick Koston ca9500b5a8 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 15:44:03 -05:00
J. Nick Koston b25e30c0b7 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 15:44:02 -05:00
J. Nick Koston cce6f71076 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 15:38:05 -05:00
J. Nick Koston 36790f78ab Merge branch 'platformio-pch-libretiny' of https://github.com/esphome/esphome into platformio-pch-libretiny 2026-08-25 15:38:04 -05:00
J. Nick Koston f215e3f45b Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 15:38:03 -05:00
J. Nick Koston fcaf17dafa Merge branch 'platformio-pch-rp2' of https://github.com/esphome/esphome into platformio-pch-rp2 2026-08-25 15:38:03 -05:00
J. Nick Koston 76ed94fe28 Merge branch 'esp32-idf-pch' of https://github.com/esphome/esphome into esp32-idf-pch 2026-08-25 15:38:02 -05:00
J. Nick Koston be60eca212 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 15:37:16 -05:00
J. Nick Koston b24bfa7552 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 15:37:16 -05:00
J. Nick Koston 1b325a66ce Warn when a consumer ignores the precompiled header 2026-08-25 15:37:15 -05:00
J. Nick Koston 273e5f22db Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 15:37:03 -05:00
pre-commit-ci-lite[bot] f198964bc5 apply automatic formatting fixes 2026-08-25 20:32:46 +00:00
pre-commit-ci-lite[bot] 6431f6da35 apply automatic formatting fixes 2026-08-25 20:32:42 +00:00
pre-commit-ci-lite[bot] 4c26f23e76 apply automatic formatting fixes 2026-08-25 20:32:17 +00:00
J. Nick Koston f438e5a19a Merge branch 'platformio-pch-libretiny' of https://github.com/esphome/esphome into platformio-pch-libretiny 2026-08-25 15:29:59 -05:00
J. Nick Koston 92233e0ff2 Merge branch 'platformio-pch-rp2' of https://github.com/esphome/esphome into platformio-pch-rp2 2026-08-25 15:29:58 -05:00
J. Nick Koston 702da28c9c Merge branch 'esp32-idf-pch' of https://github.com/esphome/esphome into esp32-idf-pch 2026-08-25 15:29:58 -05:00
J. Nick Koston 8421fe0170 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 15:29:16 -05:00
J. Nick Koston 91eb7f2f7c Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 15:29:16 -05:00
J. Nick Koston 88bcb7ff03 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 15:29:16 -05:00
J. Nick Koston 009e28998d Merge branch 'esp8266-native-pch' into platformio-pch-libretiny 2026-08-25 15:28:36 -05:00
J. Nick Koston e52af0dfa8 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 15:17:14 -05:00
J. Nick Koston 250c5bf606 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 15:17:14 -05:00
J. Nick Koston 649727fcfb Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 15:17:14 -05:00
pre-commit-ci-lite[bot] f5fd82bc42 apply automatic formatting fixes 2026-08-25 20:08:23 +00:00
J. Nick Koston 958ea20cc4 Use the shared pch registration helper 2026-08-25 15:06:29 -05:00
J. Nick Koston 243b8a871c Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 15:05:58 -05:00
J. Nick Koston 352129e77e Use the shared pch registration helper 2026-08-25 15:05:44 -05:00
J. Nick Koston e0a7347f41 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 15:05:10 -05:00
J. Nick Koston ce37667b1d Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 15:05:08 -05:00
J. Nick Koston 0007dfab70 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 14:38:16 -05:00
J. Nick Koston 52176c4d59 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 14:38:16 -05:00
J. Nick Koston aa6e3f74fd Add the OBJECT_DEPENDS edge so a rebuilt gch recompiles its consumers 2026-08-25 14:38:04 -05:00
J. Nick Koston e0662b1551 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 14:29:11 -05:00
J. Nick Koston 61009e1162 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 14:29:10 -05:00
J. Nick Koston 078e918bdb Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 14:29:10 -05:00
J. Nick Koston d5c044c7b3 [libretiny] Enable the PlatformIO precompiled header 2026-08-25 14:23:14 -05:00
J. Nick Koston 94b02b46f4 [rp2] Enable the PlatformIO precompiled header 2026-08-25 14:23:14 -05:00
J. Nick Koston d081632c53 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 14:21:46 -05:00
J. Nick Koston 04648a8044 Merge branch 'esp32-idf-pch' of https://github.com/esphome/esphome into esp32-idf-pch 2026-08-25 14:18:56 -05:00
J. Nick Koston 21c9dd33e0 Use native separators in the pch test fixture paths 2026-08-25 14:18:46 -05:00
J. Nick Koston d3f6cbe14a Patch the direct ccache probe in the usable-binary test 2026-08-25 14:06:32 -05:00
J. Nick Koston a6c4c46930 Merge branch 'esp8266-native-pch' into esp32-idf-pch
# Conflicts:
#	esphome/espidf/framework.py
2026-08-25 14:04:35 -05:00
J. Nick Koston 337ecfb8f9 Select the pch TU by source path, expand response files, and key the failure marker on the command 2026-08-25 13:58:34 -05:00
J. Nick Koston b08615f92f Merge remote-tracking branch 'origin/esp32-idf-pch' into esp32-idf-pch 2026-08-25 13:50:13 -05:00
J. Nick Koston 8625e02b8e Cover the remaining pch branches 2026-08-25 13:50:01 -05:00
J. Nick Koston f2204d155a Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 13:46:35 -05:00
J. Nick Koston d46518d890 Cover the pch compile-command and failure paths 2026-08-25 13:45:43 -05:00
J. Nick Koston f866f878de Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 13:44:47 -05:00
J. Nick Koston 59fc8dd2bb Merge remote-tracking branch 'origin/esp32-idf-pch' into esp32-idf-pch 2026-08-25 13:39:50 -05:00
J. Nick Koston 149d4d24a1 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 13:39:50 -05:00
J. Nick Koston 35104faaba Merge branch 'esp8266-native-pch' into esp32-idf-pch
# Conflicts:
#	esphome/espidf/framework.py
2026-08-25 13:38:03 -05:00
J. Nick Koston c27f97b8f9 [esp32] Precompile the shared core headers for src compiles 2026-08-25 13:37:42 -05:00
J. Nick Koston 71cf6049aa [esp32] Precompile the shared core headers for src compiles 2026-08-25 13:33:56 -05:00
34 changed files with 2644 additions and 96 deletions
+13
View File
@@ -21,6 +21,7 @@ on:
- "esphome/core/**"
- "esphome/writer.py"
- "esphome/build_gen/**"
- "esphome/build_helpers/**"
- "esphome/espidf/**"
- "esphome/platformio/**"
- "esphome/components/bk72xx/**"
@@ -197,6 +198,7 @@ jobs:
# the default.
id:
- esp8266-arduino
- esp8266-arduino-native
- esp32-arduino-platformio
- esp32-arduino-esp-idf
- esp32-idf-platformio
@@ -207,6 +209,16 @@ jobs:
- ln882x-arduino
- nrf52
- host
# Strict by default so a new matrix id cannot silently join in the
# degrade-quietly mode the knob exists to catch.
# Opt-outs: libretiny GCC rejects its own pch until a toolchain bump.
include:
- id: bk72xx-arduino
pch_strict: "0"
- id: rtl87xx-arduino
pch_strict: "0"
- id: ln882x-arduino
pch_strict: "0"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download image artifact
@@ -218,6 +230,7 @@ jobs:
- name: Compile ${{ matrix.id }}
run: |
docker run --rm \
-e ESPHOME_PCH_STRICT="${{ matrix.pch_strict || '1' }}" \
-v "${{ github.workspace }}/docker/test_configs:/config" \
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
compile "${{ matrix.id }}.yaml"
+4 -2
View File
@@ -1298,8 +1298,10 @@ jobs:
echo ""
# ESP8266 Arduino built directly (no PlatformIO); compile validates
# config first, so a separate config pass is redundant.
python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests
# config first, so a separate config pass is redundant. Strict pch:
# exercises the native ninja pch (and its probe edge) against real
# component configs; the docker matrix smoke-tests both toolchains.
ESPHOME_PCH_STRICT=1 python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests
device-builder:
name: Test downstream esphome/device-builder
@@ -0,0 +1,8 @@
esphome:
name: docker-test-esp8266-native
esp8266:
board: d1_mini
toolchain: arduino
logger:
+34 -3
View File
@@ -39,8 +39,13 @@ from esphome.build_helpers.pch import (
PCH_HEADER_NAME,
mark_pch_emitted,
pch_checksum,
pch_consumer_escalation,
pch_degraded,
pch_disabled_degraded,
pch_enabled,
pch_header_text,
pch_probe_args,
pch_strict,
)
from esphome.components.esp8266 import build_surgery
from esphome.components.esp8266.boards import (
@@ -1231,6 +1236,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
"A -include in build_flags prevents the precompiled header from "
"loading; compiling without it"
)
pch_degraded("a user -include precedes the pch")
elif pch_enabled():
# C++ src edges swap the force-includes for one precompiled prefix
# header (same content plus defines.h); C/assembly keep srcflags
@@ -1264,6 +1270,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
_LOGGER.warning(
"Could not establish the pch identity; compiling without it: %s", err
)
pch_degraded(f"identity unknown: {err}")
else:
_LOGGER.info(
"Compiling with a precompiled header "
@@ -1283,13 +1290,37 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
if src_other:
lines.append(f" flags = {' '.join(src_other)}")
# Relative -include: absolute would break cross-device ccache.
# -Wno-error keeps a rejected .gch a warning under user -Werror
# -Wno-error keeps a rejected .gch a warning under user -Werror;
# strict inverts it so any consumer rejection reds the build
# (rejection is per-process, so the probe alone cannot prove
# the consumers)
escalation = pch_consumer_escalation()
cxx_parts = src_other + [
f"-Winvalid-pch -Wno-error=invalid-pch -include {PCH_HEADER_NAME}"
f"-Winvalid-pch {escalation} -include {PCH_HEADER_NAME}"
]
lines.append(f"srccxxflags = {' '.join(cxx_parts)}")
src_cxx_override = ("$srccxxflags", gch)
pch_dep = gch
if pch_strict():
# Consumers wait on the probe stamp, so an unloadable .gch
# reds the build here instead of warning ~100 times
probe = " ".join(pch_probe_args(PCH_HEADER_NAME, source=os.devnull))
lines.append("rule pchprobe")
# $out only expands in rule text, hence the inline stamp
lines.append(
f" command = $cxx $cxxflags $flags {probe}"
" && $python $buildtool touch $out"
)
lines.append(" description = PCHPROBE $out")
# Runs when the .gch is (re)built; strict consumer -Werror
# covers a cached .gch this process cannot load
lines.append(f"build esphome_pch.probe: pchprobe {gch}")
if src_other:
lines.append(f" flags = {' '.join(src_other)}")
pch_dep = f"{gch} esphome_pch.probe"
src_cxx_override = ("$srccxxflags", pch_dep)
mark_pch_emitted()
else:
pch_disabled_degraded()
src_objs = _ninja_compile_edges(
lines,
_collect_sources(src_dir),
+11 -1
View File
@@ -6,6 +6,7 @@ started esphome and must not depend on the package being importable.
Subcommands:
ar <ar-binary> <archive> <rspfile> remove stale archive, then ``ar rcs``
copy <src> <dst> copy a file
touch <path> create/update a stamp file
The ar rspfile carries one object path per line (the generating rule must
use ``$in_newline``, never ``$in``).
@@ -83,9 +84,18 @@ def _run_copy(src: str, dst: str) -> int:
return 0
def _run_touch(path: str) -> int:
try:
Path(path).touch()
except OSError as err:
print(f"touch: {path} failed: {err}", file=sys.stderr)
return 1
return 0
# mode -> (handler, expected operand count); surplus argv means a
# mis-specified ninja rule and must error, not silently drop operands
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2)}
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2), "touch": (_run_touch, 1)}
def main() -> int:
+57 -1
View File
@@ -4,6 +4,14 @@ import json
import logging
from pathlib import Path
from esphome.build_helpers import pch
from esphome.build_helpers.pch import (
PCH_DEFAULT_HEADERS,
PCH_HEADER_NAME,
mark_pch_emitted,
pch_enabled,
pch_header_text,
)
from esphome.components.esp32 import (
get_esp32_variant,
get_excluded_builtin_components,
@@ -279,7 +287,47 @@ idf_component_register(
target_link_options(${{COMPONENT_LIB}} PUBLIC
{link_opts_str}
)
"""
{_pch_cmake()}"""
def _pch_cmake() -> str:
"""Consumer block for the component CMakeLists. Baked at generation:
a strict-knob flip takes effect on the next esphome compile; a
hand-run idf.py keeps the old one."""
return pch.pch_cmake_consumer("${COMPONENT_LIB}", "${app_sources}")
def prepare_pch() -> None:
"""Build the .gch right before ninja, after every reconfigure, so the
compile_commands.json flags and the sdkconfig are the settled ones."""
if not pch_enabled():
# Self-cleaning escape hatch: drop any previously built .gch
pch.discard_pch(CORE.relative_build_path("build"))
pch.pch_disabled_degraded()
return
sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}")
try:
sdkconfig = sdkconfig_path.read_text(encoding="utf-8")
except OSError as err:
# Fail closed: the sdkconfig is the .sum's only config identity for
# sdkconfig.h-only options; a stand-in marker would collide
_LOGGER.warning(
"Could not read %s; compiling without the pch: %s", sdkconfig_path, err
)
pch.discard_pch(CORE.relative_build_path("build"))
pch.pch_degraded(f"sdkconfig unreadable: {err}")
return
pch.prepare_pch(
CORE.relative_build_path("build"),
PCH_DEFAULT_HEADERS,
(
str(idf_version()),
CORE.cpp_standard or "",
sdkconfig,
*get_project_compile_flags(),
*get_project_cxx_compile_flags(),
),
)
def write_project(
@@ -301,6 +349,14 @@ def write_project(
get_component_cmakelists(),
)
if pch_enabled():
write_file_if_changed(
CORE.relative_build_path("build", PCH_HEADER_NAME),
pch_header_text(PCH_DEFAULT_HEADERS),
)
# Consumers carry the -include; gate the ccache relaxation on it
mark_pch_emitted()
# Snapshot the exclusion set so has_outdated_files() can trigger a
# discovery reconfigure when it changes. Excluded components never
# register in project_description.json, so re-including one (e.g. a
+7 -2
View File
@@ -21,12 +21,13 @@ def _ccache_runs(ccache: str) -> bool:
)
def parse_enable_env(name: str) -> bool | None:
def parse_enable_env(name: str, strict: bool = False) -> bool | None:
"""Strictly parse an on/off environment knob; None when unset or invalid.
``bool(str)`` truthiness would flip ``no``/``off`` to enabled, so only
1/true/yes/on and 0/false/no/off count; anything else warns and reads
as unset so the caller's default policy applies.
as unset so the caller's default policy applies — or raises when
``strict`` (a typo must not silently disable a CI gate).
"""
raw = os.environ.get(name)
if raw is None:
@@ -39,6 +40,10 @@ def parse_enable_env(name: str) -> bool | None:
return True
if lowered in FALSY_ENV_STRINGS:
return False
if strict:
from esphome.core import EsphomeError
raise EsphomeError(f"Unrecognized {name}={raw!r}; use 1 or 0")
_LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw)
return None
+9 -9
View File
@@ -76,7 +76,7 @@ def _is_esphome_src(file: str) -> bool:
)
def _split_command(command: str) -> list[str]:
def split_command(command: str) -> list[str]:
r"""Tokenize a compile_commands.json / response-file command string.
On Windows, tokenize per Windows ``argv`` rules via ``CommandLineToArgvW``.
@@ -112,7 +112,7 @@ def _split_command(command: str) -> list[str]:
ctypes.windll.kernel32.LocalFree(argv)
def _expand_response_files(tokens: list[str], directory: Path) -> list[str]:
def expand_response_files(tokens: list[str], directory: Path) -> list[str]:
"""Inline any ``@response-file`` arguments (paths relative to ``directory``).
GCC response files embed flags that must be expanded so GCC-only flags
@@ -127,8 +127,8 @@ def _expand_response_files(tokens: list[str], directory: Path) -> list[str]:
rf = directory / rf
try:
out.extend(
_expand_response_files(
_split_command(rf.read_text(encoding="utf-8")), directory
expand_response_files(
split_command(rf.read_text(encoding="utf-8")), directory
)
)
continue
@@ -157,7 +157,7 @@ def _pick_entry(entries: list[dict]) -> dict:
_LAUNCHER_STEMS = frozenset({"ccache", "sccache", "distcc", "icecc", "buildcache"})
def _is_launcher(token: str) -> bool:
def is_launcher(token: str) -> bool:
return Path(token).stem.lower() in _LAUNCHER_STEMS
@@ -175,7 +175,7 @@ def parse_entry(
) -> tuple[str, list[str], list[str], list[str]]:
"""Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags)."""
directory = Path(entry["directory"])
tokens = _expand_response_files(_split_command(entry["command"]), directory)
tokens = expand_response_files(split_command(entry["command"]), directory)
def _include(raw: str) -> str:
# Resolve against the entry's ``directory`` so cached idedata works
@@ -191,7 +191,7 @@ def parse_entry(
if not tokens:
# An empty command, or one that was only the launcher; fail by name
raise ValueError(f"empty compile command for {entry.get('file')}")
if _is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
if is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
# Stale DB built with a launcher this run no longer configures; the
# real compiler is the next token
_LOGGER.warning("Stripping unconfigured launcher %s", tokens[0])
@@ -311,7 +311,7 @@ def _cache_usable(cached: object) -> bool:
if not isinstance(cached, dict) or "cc_path" not in cached:
return False
cxx_path = cached.get("cxx_path")
if not isinstance(cxx_path, str) or _is_launcher(cxx_path):
if not isinstance(cxx_path, str) or is_launcher(cxx_path):
return False
includes = cached.get("includes")
return isinstance(includes, dict) and isinstance(includes.get("build"), list)
@@ -359,7 +359,7 @@ def load_or_build_idedata(
def reject_launcher_compiler(cxx_path: str) -> None:
"""Reject a compile DB naming a launcher (ccache) as the compiler; it
must never be probed, cached, or consumed."""
if _is_launcher(cxx_path):
if is_launcher(cxx_path):
raise EsphomeError(
f"compile_commands.json names the launcher {cxx_path} as the "
"compiler; the compile database is unusable"
+428 -3
View File
@@ -7,17 +7,26 @@ it too; Arduino.h visibility there is intended (esphome#8693).
from __future__ import annotations
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from contextlib import suppress
from dataclasses import dataclass
import hashlib
import json
import logging
import os
from pathlib import Path
import posixpath
import re
import stat
import subprocess
from esphome.build_helpers.ccache import parse_enable_env
from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env
from esphome.build_helpers.idedata import (
CXX_SOURCE_SUFFIXES,
expand_response_files,
is_launcher,
split_command,
)
_DOMAIN = "pch"
@@ -56,6 +65,27 @@ PCH_ARTIFACT_NAMES = (
# The core defines header every backend anchors its prefix on.
PCH_CORE_HEADER = "esphome/core/defines.h"
# Guarded curated-prefix wrapper for PlatformIO backends without framework
# force-includes (host, esp32); folded by the pch script via build_src_flags.
PCH_PREFIX_HEADER = "esphome/core/pch_prefix.h"
# Prefix-header contents for backends that inject a curated set (rather
# than mirroring the TUs' own force-includes), defines.h first so USE_*
# macros exist for the rest. Deliberately hard-coded: frequency-derived
# sets measured no better and kept selecting headers that cannot compile
# standalone (X-macro, platform-variant). Every entry must be safe to
# include first in an empty TU. Caveat: application.h/automation.h become
# ambiently visible, so a TU missing those #includes still builds on such
# backends; ESPHOME_PCH_ENABLE=0 restores the strict view.
PCH_DEFAULT_HEADERS = (
PCH_CORE_HEADER,
"esphome/core/component.h",
"esphome/core/helpers.h",
"esphome/core/log.h",
"esphome/core/application.h",
"esphome/core/automation.h",
)
# ccache cannot hash through a .gch; CCACHE_PCH_EXTSUM makes it hash the
# .sum sidecar instead of the .gch bytes, which are not reproducible.
# Keep in sync with the literals in platformio/pch.py.script.
@@ -66,6 +96,9 @@ _CCACHE_PCH_ENV = {
# Both include forms: an angle include resolving under src/ must enter the
# digest too; ones that do not resolve simply end the walk
# Compiler failures that clear on their own must not latch the .failed marker
_TRANSIENT_ERRORS = ("No space left", "Cannot allocate", "Resource temporarily")
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
@@ -74,6 +107,81 @@ def pch_enabled() -> bool:
return parse_enable_env("ESPHOME_PCH_ENABLE") is not False
def pch_strict() -> bool:
"""CI knob: ``ESPHOME_PCH_STRICT=1`` turns pch degrade paths fatal.
A set-but-unrecognized value raises: a typo must not silently turn
the gate into a no-op that proves nothing.
"""
return parse_enable_env("ESPHOME_PCH_STRICT", strict=True) is True
def pch_degraded(reason: str) -> None:
"""Every degrade path funnels through here; strict mode raises."""
if pch_strict():
from esphome.core import EsphomeError
raise EsphomeError(f"ESPHOME_PCH_STRICT: {reason}")
def pch_disabled_degraded() -> None:
"""Strict CI must not read "no pch at all" as success."""
pch_degraded("pch disabled by ESPHOME_PCH_ENABLE")
def pch_probe_tail(source: str = "-") -> list[str]:
"""The syntax-only compile shared by the probe and its baseline."""
return ["-fsyntax-only", "-x", "c++", source]
def pch_probe_args(header: str, source: str = "-") -> list[str]:
"""Flags that load-check a built .gch via a syntax-only compile.
Rejection must be a nonzero exit (never just a wording match), so the
invalid-pch class is always escalated. ``source`` defaults to stdin
(host independent); the ninja probe edge passes a real file.
"""
return [
"-Winvalid-pch",
"-Werror=invalid-pch",
"-include",
header,
*pch_probe_tail(source),
]
def pch_consumer_escalation() -> str:
"""Consumer-side invalid-pch flag: strict reds the build on rejection
(per-process, so the probe alone cannot prove the consumers)."""
return "-Werror=invalid-pch" if pch_strict() else "-Wno-error=invalid-pch"
def pch_cmake_consumer(target: str, sources_var: str) -> str:
"""Emit the CMake block making ``target``'s C++ sources consume the
pch; empty when disabled. OBJECT_DEPENDS is on the header, not the
.gch (pch-baked headers drop out of TU depfiles); the -include stays
relative — an absolute path would poison ccache keys."""
if not pch_enabled():
return ""
escalation = pch_consumer_escalation()
return f"""
# ESPHome precompiled header (see esphome/build_helpers/pch.py).
# The touch keeps OBJECT_DEPENDS satisfiable when the build system itself
# wiped the build dir after the header was written (west --pristine)
if(NOT EXISTS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
file(TOUCH "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
endif()
target_compile_options({target} PRIVATE
"$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"
"$<$<COMPILE_LANGUAGE:CXX>:{escalation}>"
"$<$<COMPILE_LANGUAGE:CXX>:-include>"
"$<$<COMPILE_LANGUAGE:CXX>:{PCH_HEADER_NAME}>"
)
set_source_files_properties({sources_var} PROPERTIES
OBJECT_DEPENDS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
"""
def ccache_pch_env() -> dict[str, str]:
"""Settings ccache needs to cache compiles that consume the .gch;
empty unless this build actually emitted one. User-set values win.
@@ -104,10 +212,37 @@ def ccache_pch_env() -> dict[str, str]:
return env
def guarded_prepare(build_dir: Path, prepare: Callable[[], None]) -> None:
"""Run a backend's pch preparation; an optional speedup must never
abort the build. Strict is read first so its own knob error cannot
mask the real failure; discard_pch raises if a stale .gch survives;
the header is ensured so OBJECT_DEPENDS stays satisfiable."""
try:
prepare()
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
strict = pch_strict()
discard_pch(build_dir)
if strict:
raise
header = build_dir / PCH_HEADER_NAME
if not header.exists():
try:
header.touch()
except OSError as err:
# The coming OBJECT_DEPENDS error would hide the real cause
_LOGGER.warning("Could not create the pch placeholder: %s", err)
_LOGGER.warning(
"Precompiled header setup failed; compiling without it", exc_info=True
)
def pch_extra_scripts() -> list[str]:
"""The extra_scripts entries a PlatformIO platform registers for the
pch; empty when disabled (the script itself has no enable check)."""
return ["post:pch.py"] if pch_enabled() else []
if not pch_enabled():
pch_disabled_degraded()
return []
return ["post:pch.py"]
def pch_header_text(include_headers: Iterable[str]) -> str:
@@ -176,3 +311,293 @@ def pch_checksum(
digest.update(item.encode(errors="surrogateescape"))
digest.update(b"\0")
return digest.hexdigest()
# Tokens dropped when retargeting a TU's flags at the prefix header
# (the pch compile must not touch depfiles)
_PCH_STRIP_FLAGS_WITH_ARG = frozenset({"-o", "-c", "-MT", "-MF", "-MQ"})
_PCH_STRIP_FLAGS = frozenset({"-MD", "-MMD", "-MP", "-MM", "-M"})
def pch_compile_command(
build_dir: Path, header: Path, gch: Path
) -> tuple[list[str], Path] | None:
"""The exact src C++ flags from compile_commands.json retargeted at the
header, with the directory they resolve against (relative -I paths must
be expanded and executed from the same root); None (logged) when no
configured C++ TU is available yet."""
from esphome.core import CORE
try:
entries = json.loads(
(build_dir / "compile_commands.json").read_text(encoding="utf-8")
)
except (OSError, json.JSONDecodeError) as err:
# Configure already succeeded, so an unusable DB is a real anomaly
_LOGGER.warning("No usable compile database, skipping pch: %s", err)
return None
if not isinstance(entries, list):
_LOGGER.warning("Malformed compile database, skipping pch")
return None
# CMake may spell paths through a symlink differently than CORE does
# (macOS /tmp vs /private/tmp), so compare resolved paths
src_root = Path(CORE.relative_src_path()).resolve()
entry = next(
(
e
for e in entries
if isinstance(e, dict)
and isinstance(e.get("file"), str)
and e["file"].endswith(CXX_SOURCE_SUFFIXES)
and Path(e["file"]).resolve().is_relative_to(src_root)
),
None,
)
if entry is None:
_LOGGER.warning("No src C++ entry in the compile database, skipping pch")
return None
directory = entry.get("directory")
cmd_dir = Path(directory) if isinstance(directory, str) and directory else build_dir
command = entry.get("command")
tokens = expand_response_files(
split_command(command if isinstance(command, str) else ""), cmd_dir
)
# A DB recorded with ccache enabled prefixes the compiler with the
# launcher; the .gch must be compiled directly
if tokens and is_launcher(tokens[0]):
tokens = tokens[1:]
if not tokens:
# "arguments"-style or empty entries must skip, not spawn "-x ..."
_LOGGER.warning("Compile database entry has no usable command, skipping pch")
return None
args: list[str] = []
arg_it = iter(tokens)
for tok in arg_it:
if tok in _PCH_STRIP_FLAGS_WITH_ARG:
next(arg_it, None)
continue
if tok in _PCH_STRIP_FLAGS:
continue
if tok == "-include":
# Drop only the injected prefix; user force-includes must reach
# the .gch compile or GCC rejects it over the macro mismatch
inc = next(arg_it, "")
if not inc.endswith(PCH_HEADER_NAME):
args.extend(("-include", inc))
continue
args.append(tok)
return [*args, "-x", "c++-header", "-c", str(header), "-o", str(gch)], cmd_dir
def _log_pch_in_use() -> None:
# The only place a user can discover the knob; emitted only once a
# .gch is actually fresh or being built
_LOGGER.info(
"Compiling with a precompiled header (set ESPHOME_PCH_ENABLE=0 to disable)"
)
def _read_stamp(path: Path) -> str:
"""A corrupt sidecar must read as stale, not kill the pch forever."""
try:
return path.read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
return ""
def discard_pch(build_dir: Path) -> None:
"""Remove the pch sidecars so a stale .gch is never consumed.
Bumps the header only when a .gch was actually removed: TUs compiled
against it have incomplete depfiles, while a repeat failure with no
.gch must not force a full rebuild every build. A .gch that survives
an unlink failure would be consumed silently (wrong output, not a
slow build), so that raises.
"""
header = build_dir / PCH_HEADER_NAME
gch = Path(f"{header}.gch")
had_gch = gch.is_file()
errors = []
for sidecar in (gch, Path(f"{gch}.sum")):
try:
sidecar.unlink(missing_ok=True)
except OSError as err:
if sidecar.is_file():
from esphome.core import EsphomeError
raise EsphomeError(
f"Could not discard the stale precompiled header: {err}"
) from err
errors.append(err)
for err in errors:
_LOGGER.warning("Could not discard the pch sidecars: %s", err)
if had_gch and header.is_file():
with suppress(OSError):
os.utime(header)
def prepare_pch(
build_dir: Path, include_headers: tuple[str, ...], extra: Iterable[str]
) -> None:
"""Compile ``build_dir``'s .gch from compile_commands.json flags and
write its ccache .sum.
The .sum doubles as the freshness stamp and folds in the compile
command, so a flag-only change rebuilds the .gch; ``extra`` carries
backend identity (framework version, sdkconfig, ...). A failed
compile falls back to the plain header include.
"""
from esphome.core import CORE
header = build_dir / PCH_HEADER_NAME
gch = Path(f"{header}.gch")
sum_path = Path(f"{gch}.sum")
cmd_and_dir = pch_compile_command(build_dir, header, gch)
if cmd_and_dir is None:
# Freshness cannot be validated; a leftover .gch must not be consumed
discard_pch(build_dir)
pch_degraded("no usable compile command")
return
cmd, cmd_dir = cmd_and_dir
# Strip like ccache's rewriting (user CCACHE_BASEDIR wins); the raw
# build path covers unresolved (symlinked) spellings
cmd_id = (
" ".join(cmd)
.replace(effective_ccache_basedir(), "")
.replace(str(CORE.build_path), "")
)
try:
checksum = pch_checksum(
CORE.relative_src_path(),
include_headers,
(
# The closure is sorted, so root order only enters via the text
pch_header_text(include_headers),
*extra,
cmd_id,
),
)
except (OSError, UnicodeError) as err:
# Identity unknown: a stale cache entry must never be served
_LOGGER.warning(
"Could not establish the pch identity; compiling without it: %s", err
)
discard_pch(build_dir)
pch_degraded(f"identity unknown: {err}")
return
failed_marker = Path(f"{gch}.failed")
def _run(
run_cmd: list[str], what: str, stdin: str | None = None
) -> subprocess.CompletedProcess | None:
"""Spawn one pch tool step; environmental failures discard and
degrade (None): spawn/IO/timeout errors and signal kills never
latch the marker."""
try:
proc = subprocess.run(
run_cmd,
cwd=cmd_dir,
# C locale keeps diagnostics matchable by _TRANSIENT_ERRORS
env={**os.environ, "LC_ALL": "C"},
input=stdin,
capture_output=True,
text=True,
check=False,
timeout=300,
)
except (OSError, subprocess.SubprocessError) as err:
_LOGGER.warning("Precompiled header %s did not run: %s", what, err)
discard_pch(build_dir)
pch_degraded(f"{what} did not run: {err}")
return None
if proc.returncode < 0:
# Killed by a signal (OOM, ^C): environmental, do not latch
_LOGGER.warning(
"Precompiled header %s was killed (signal %d); retrying next build",
what,
-proc.returncode,
)
discard_pch(build_dir)
pch_degraded(f"{what} killed by signal {-proc.returncode}")
return None
return proc
def _fail(error: str, reason: str, latch: bool) -> None:
"""Discard and degrade; deterministic failures latch when asked."""
_LOGGER.warning(
"Precompiled header failed; compiling without it: %s", error[:400]
)
# Latching paths keep the full compiler output recoverable
_LOGGER.debug("Full pch output: %s", error)
discard_pch(build_dir)
if latch and not any(m in error for m in _TRANSIENT_ERRORS):
# Skip retries until a header/flag/backend-identity/command change
failed_marker.write_text(checksum + "\n", encoding="utf-8")
os.utime(header)
pch_degraded(f"{reason}: {error[:200]}")
def _probe(latch: bool = True) -> None:
"""Load-check the built .gch: some toolchains build one they then
refuse to load (per-process ASLR). Dep flags are already stripped
from cmd, so no -MF is needed; cmd ends with the fixed
"-x c++-header -c -o" tail. A cached-header rejection may not
reproduce (per-process), so that caller passes latch=False."""
if cmd[-6:-4] != ["-x", "c++-header"]:
# The slice below depends on pch_compile_command's fixed tail
_LOGGER.warning("Unexpected pch command shape: %s", cmd[-6:])
discard_pch(build_dir)
pch_degraded("unexpected pch command shape")
return
base = cmd[:-6]
probe = _run([*base, *pch_probe_args(str(header))], "probe", stdin="")
if probe is None:
return
if probe.returncode != 0:
# Disambiguate: only blame the pch when the same compile passes
# without it; a failing baseline is its own (latchable) problem
baseline = _run([*base, *pch_probe_tail()], "probe baseline", stdin="")
if baseline is None:
return
if baseline.returncode == 0:
error = probe.stderr.strip() or f"exit code {probe.returncode}"
_fail(error, "toolchain cannot load the pch", latch=latch)
else:
error = baseline.stderr.strip() or f"exit code {baseline.returncode}"
_fail(error, "probe cannot run at all", latch=latch)
if gch.is_file() and _read_stamp(sum_path) == checksum:
_log_pch_in_use()
if pch_strict():
# Rejection is per-process, so a cached .gch must re-prove
# loadability for the strict gate (CI-only cost); no latch,
# since the rejection may not reproduce either
_probe(latch=False)
return
if _read_stamp(failed_marker) == checksum:
_LOGGER.info(
"Precompiled header disabled after an earlier failure; delete %s to retry",
failed_marker,
)
pch_degraded("earlier failure latched")
return
_log_pch_in_use()
result = _run(cmd, "compile")
if result is None:
return
error = None
if result.returncode != 0:
error = result.stderr.strip() or f"exit code {result.returncode}"
elif not gch.is_file():
error = "compiler produced no .gch"
if error is not None:
_fail(error, "compile failed", latch=True)
return
_probe()
if not gch.is_file():
# The probe discarded a rejected or unrunnable .gch
return
failed_marker.unlink(missing_ok=True)
sum_path.write_text(checksum + "\n", encoding="utf-8")
# Consumers depend on the header (depfiles cannot see through a .gch);
# bump it so users of the previous .gch recompile
os.utime(header)
+9
View File
@@ -10,6 +10,7 @@ import subprocess
from typing import Any
from esphome import yaml_util
from esphome.build_helpers.pch import PCH_PREFIX_HEADER, pch_enabled, pch_extra_scripts
import esphome.codegen as cg
from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION
from esphome.config_helpers import filter_source_files_from_defines
@@ -57,6 +58,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority
from esphome.espidf.component import generate_idf_components
import esphome.final_validate as fv
from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed
from esphome.platformio.toolchain import copy_pch_script
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
from esphome.writer import clean_build, clean_cmake_cache
@@ -2455,6 +2457,11 @@ async def to_code(config):
cg.add_platformio_option("lib_ldf_mode", "off")
cg.add_platformio_option("lib_compat_mode", "strict")
# CI-speed only: this toolchain is being dropped, so the pch gets
# the same curated prefix as host with no further investment
cg.add_platformio_option("extra_scripts", pch_extra_scripts())
if pch_enabled():
cg.add_platformio_option("build_src_flags", f"-include {PCH_PREFIX_HEADER}")
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
cg.add_platformio_option("board", config[CONF_BOARD])
cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE])
@@ -3372,6 +3379,8 @@ def _write_idf_component_yml():
def copy_files():
_write_sdkconfig()
_write_idf_component_yml()
if not CORE.using_toolchain_esp_idf:
copy_pch_script()
if "partitions.csv" not in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES]:
flash_size = CORE.data[KEY_ESP32][KEY_FLASH_SIZE]
+12 -2
View File
@@ -1,3 +1,4 @@
from esphome.build_helpers.pch import PCH_PREFIX_HEADER, pch_enabled, pch_extra_scripts
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
@@ -10,7 +11,7 @@ from esphome.const import (
ThreadModel,
)
from esphome.core import CORE
from esphome.platformio.toolchain import copy_ccache_script
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
from esphome.types import ConfigType
from .const import KEY_HOST
@@ -55,9 +56,18 @@ async def to_code(config: ConfigType) -> None:
cg.add_platformio_option("platform", "platformio/native")
cg.add_platformio_option("lib_ldf_mode", "off")
cg.add_platformio_option("lib_compat_mode", "strict")
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", *pch_extra_scripts()])
if pch_enabled():
# Curated prefix for the pch (the script folds it plus defines.h):
# host has no framework force-includes, and the per-TU cost is the
# STL closure behind the core headers. Measured -43% compile CPU.
# Gated so ESPHOME_PCH_ENABLE=0 restores the strict view. When the
# .gch fails to build or load, the force-include stays and every TU
# parses the closure as text: correct, but slower than no pch.
cg.add_platformio_option("build_src_flags", f"-include {PCH_PREFIX_HEADER}")
# Called by writer.py
def copy_files() -> None:
copy_ccache_script()
copy_pch_script()
+4 -2
View File
@@ -2,6 +2,7 @@ import json
import logging
from pathlib import Path
from esphome.build_helpers.pch import pch_extra_scripts
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
@@ -26,7 +27,7 @@ from esphome.const import (
from esphome.core import CORE
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import copy_file_if_changed
from esphome.platformio.toolchain import copy_ccache_script
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
from esphome.storage_json import StorageJSON
from . import gpio # noqa: F401
@@ -513,7 +514,7 @@ async def component_to_code(config):
# it for project source files only. GCC uses the last -O flag.
build_src_flags += " -Os"
cg.add_platformio_option("build_src_flags", build_src_flags)
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", *pch_extra_scripts()])
# IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops).
# On other families, patch_linker.py routes .sram.text into the right
# RAM-executable output section and prints a post-link placement summary.
@@ -619,3 +620,4 @@ def copy_files() -> None:
CORE.relative_build_path("patch_linker.py"),
)
copy_ccache_script()
copy_pch_script()
+126
View File
@@ -1,13 +1,24 @@
from __future__ import annotations
import asyncio
from functools import partial
import logging
from pathlib import Path
import re
import shutil
import stat
import subprocess
from esphome import pins
from esphome.build_helpers import pch
from esphome.build_helpers.pch import (
PCH_DEFAULT_HEADERS,
PCH_HEADER_NAME,
mark_pch_emitted,
pch_cmake_consumer,
pch_enabled,
pch_header_text,
)
import esphome.codegen as cg
from esphome.components.zephyr import (
add_extra_script,
@@ -805,6 +816,9 @@ def _generate_cmake_lists() -> bool:
")",
]
if consumer := pch_cmake_consumer("app", "${APP_SOURCES}"):
lines += consumer.splitlines()
if link_flags:
lines += [
"",
@@ -819,6 +833,66 @@ def _generate_cmake_lists() -> bool:
)
def _app_build_dir(build_dir: Path) -> Path:
"""The CMake binary dir of the app image: sysbuild nests it in a
domain dir named after the app source dir. Probed on disk (the
non-sysbuild zephyr/ output dir has no CMakeCache.txt) so it stays
truthful mid-build, unlike an SDK-version check."""
sysbuild_app = build_dir / "zephyr"
try:
cache = (sysbuild_app / "CMakeCache.txt").stat()
except (FileNotFoundError, NotADirectoryError):
return build_dir
# Other stat errors propagate; is_file() would silently mislocate the pch
return sysbuild_app if stat.S_ISREG(cache.st_mode) else build_dir
def _prepare_pch(app_dir: Path) -> None:
"""Build the .gch between the cmake and compile phases of west."""
if not pch_enabled():
pch.discard_pch(app_dir)
pch.pch_disabled_degraded()
return
# First, so OBJECT_DEPENDS is satisfied even when the pch degrades
app_dir.mkdir(parents=True, exist_ok=True)
write_file_if_changed(
app_dir / PCH_HEADER_NAME, pch_header_text(PCH_DEFAULT_HEADERS)
)
# New layout first (Zephyr >= 3.4 nests under zephyr/); fixed candidates
# keep the .sum identity deterministic and skip walking generated/
generated = app_dir / "zephyr" / "include" / "generated"
autoconf = None
for candidate in (generated / "zephyr" / "autoconf.h", generated / "autoconf.h"):
if candidate.exists():
autoconf = candidate
break
if autoconf is None:
# Fail closed: autoconf.h is the .sum's Kconfig identity
_LOGGER.warning("No autoconf.h found; compiling without the pch")
pch.discard_pch(app_dir)
pch.pch_degraded("autoconf.h missing")
return
try:
autoconf_text = autoconf.read_text(encoding="utf-8")
except OSError as err:
_LOGGER.warning(
"Could not read %s; compiling without the pch: %s", autoconf, err
)
pch.discard_pch(app_dir)
pch.pch_degraded(f"autoconf unreadable: {err}")
return
pch.prepare_pch(
app_dir,
PCH_DEFAULT_HEADERS,
(
str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
zephyr_data()[KEY_BOARD],
autoconf_text,
*get_project_compile_flags(),
),
)
def _copy_if_exists(src: Path, dst: Path) -> None:
if src.is_file():
shutil.copy2(src, dst)
@@ -871,6 +945,58 @@ def run_compile(args, config: ConfigType) -> bool:
str(source_dir),
]
if pch_enabled():
# Consumers carry the -include; gate the ccache relaxation on it
# (Zephyr auto-enables ccache as the compiler launcher when found)
mark_pch_emitted()
env.update(pch.ccache_pch_env())
# Configure first so the .gch compiles from settled compile DB
# flags. Only when the app DB is missing: input changes wipe the
# build dir, so an existing DB is settled and --cmake-only would
# reconfigure for nothing.
prepare = True
app_dir = _app_build_dir(build_dir)
if not (app_dir / "compile_commands.json").is_file():
if not run_command_ok(
west_cmd + ["--cmake-only", "--", "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"],
env=env,
stream_output=True,
cwd=str(paths["framework_path"]),
):
raise EsphomeError("nRF52 native build configure failed")
# The configure phase creates the sysbuild domain dir: re-resolve
app_dir = _app_build_dir(build_dir)
# kernel.h needs the build-time syscall headers; under sysbuild
# the target exists only in the app domain's ninja
if not run_command_ok(
[
"cmake",
"--build",
str(app_dir),
"--target",
"zephyr_generated_headers",
],
env=env,
stream_output=True,
cwd=str(paths["framework_path"]),
):
# A pch-only prerequisite: degrade, let the real build report.
# Also skip the .gch compile: it would fail on the missing
# headers and latch .gch.failed until an identity change
_LOGGER.warning(
"Zephyr header generation failed; compiling without the pch"
)
pch.discard_pch(app_dir)
pch.pch_degraded("zephyr_generated_headers failed")
prepare = False
else:
prepare = True
app_dir = _app_build_dir(build_dir)
if prepare:
pch.guarded_prepare(app_dir, partial(_prepare_pch, app_dir))
if not run_command_ok(
west_cmd,
env=env,
+7 -2
View File
@@ -6,6 +6,7 @@ from string import ascii_letters, digits
import subprocess
from typing import Any
from esphome.build_helpers.pch import pch_extra_scripts
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
@@ -33,7 +34,7 @@ from esphome.core import (
)
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed
from esphome.platformio.toolchain import copy_ccache_script
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
from esphome.storage_json import StorageJSON
from esphome.types import ConfigType
@@ -340,7 +341,10 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant])
cg.add_define(ThreadModel.SINGLE)
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", "post:post_build.py"])
cg.add_platformio_option(
"extra_scripts",
["pre:ccache.py", *pch_extra_scripts(), "post:post_build.py"],
)
conf = config[CONF_FRAMEWORK]
cg.add_platformio_option("framework", "arduino")
@@ -644,6 +648,7 @@ def copy_files() -> None:
CORE.relative_build_path("inject_lwip_include.py"),
)
copy_ccache_script()
copy_pch_script()
_generate_lwipopts_h()
if generate_pio_files():
path = CORE.relative_src_path("esphome.h")
+5 -4
View File
@@ -1143,13 +1143,14 @@ class EsphomeCore:
def add_platformio_option(
self, key: str, value: str | list[str], *, replace: bool = False
) -> None:
"""Set a platformio.ini option; list values append to an existing list
unless ``replace`` is True, which overwrites any existing value."""
"""Set a platformio.ini option; values append to an existing list
(a string as one element) unless ``replace`` is True, which
overwrites any existing value."""
new_val = value
old_val = self.platformio_options.get(key)
if not replace and isinstance(old_val, list):
assert isinstance(value, list)
new_val = old_val + value
# A user platformio_options string must merge, not assert
new_val = old_val + ([value] if isinstance(value, str) else value)
self.platformio_options[key] = new_val
def _get_variable_generator(self, id):
+8
View File
@@ -0,0 +1,8 @@
#pragma once
// Curated precompiled-header prefix for backends that force-include it via
// build_src_flags (the pch script folds it into the .gch). Guarded because
// build_src_flags also reaches C and assembly src edges.
#ifdef __cplusplus
#include "esphome/core/application.h"
#include "esphome/core/automation.h"
#endif
+27 -17
View File
@@ -16,6 +16,7 @@ from esphome.build_helpers.ccache import (
parse_enable_env,
resolve_ccache_path,
)
from esphome.build_helpers.pch import ccache_pch_env
from esphome.build_helpers.tools_cache import IDF_TOOLS_CACHE, tools_cache_path
from esphome.core import Version
from esphome.framework_helpers import (
@@ -1205,15 +1206,33 @@ def _ccache_env() -> dict[str, str]:
Only values the user has not already set in the environment are returned, so
a custom ``CCACHE_DIR`` / ``CCACHE_MAXSIZE`` / etc. is respected.
The pch settings add ``time_macros`` sloppiness process-wide; the visible
effect is a cached TU can keep an older ``esp_app_desc`` build timestamp.
"""
if not _ccache_enabled():
# The raw knob value (e.g. "disable") is still inherited by idf.py
# via os.environ, where a non-false-constant string reads as
# truthy; export the canonical off spelling instead
return {"IDF_CCACHE_ENABLE": "0"}
env = ccache_defaults_env(get_idf_tools_path() / "ccache")
env.update(ccache_pch_env())
# Exactly one canonical spelling ever reaches idf.py, whatever the
# accepted input spelling was ("enable", "yes", ...)
env["IDF_CCACHE_ENABLE"] = "1"
return env
def _ccache_enabled() -> bool:
"""Whether ESP-IDF compiles run under ccache.
IDF_CCACHE_ENABLE (the backend-native knob) wins over the shared
ESPHOME_CCACHE_ENABLE; when unset, enabled iff a runnable binary is
on PATH.
"""
# IDF_CCACHE_ENABLE (the backend-native knob) wins over the shared
# ESPHOME_CCACHE_ENABLE.
idf_knob = parse_enable_env("IDF_CCACHE_ENABLE")
if idf_knob is False:
# The raw value (e.g. "disable") is still inherited by idf.py via
# os.environ, where a non-false-constant string reads as truthy;
# export the canonical off spelling instead
return {"IDF_CCACHE_ENABLE": "0"}
return False
if idf_knob is True:
# Forced on ignores the runnability verdict, but the outcome is
# worth saying out loud. Probed directly (not via the resolver,
@@ -1233,17 +1252,8 @@ def _ccache_env() -> dict[str, str]:
"IDF_CCACHE_ENABLE=1 forces on the ccache at %s even though "
"it failed to run; idf.py will use it anyway",
)
elif resolve_ccache_path() is None:
# ESP-IDF silently skips ccache without the binary; export the
# canonical off spelling so an unparsable inherited value (or a
# probe-rejected ccache idf.py would still find) cannot enable it
return {"IDF_CCACHE_ENABLE": "0"}
env = ccache_defaults_env(get_idf_tools_path() / "ccache")
# Exactly one canonical spelling ever reaches idf.py, whatever the
# accepted input spelling was ("enable", "yes", ...)
env["IDF_CCACHE_ENABLE"] = "1"
return env
return True
return resolve_ccache_path() is not None
def get_framework_env(
+6
View File
@@ -528,6 +528,12 @@ def run_compile(config, verbose: bool) -> int:
return result.returncode
_patch_memory_segments()
# After every reconfigure so compile_commands and sdkconfig are settled
from esphome.build_gen.espidf import prepare_pch
from esphome.build_helpers.pch import guarded_prepare
guarded_prepare(CORE.relative_build_path("build"), prepare_pch)
# Build
args = []
+86 -34
View File
@@ -20,11 +20,22 @@ except Exception as err: # noqa: BLE001 -- not exported under -t nobuild
# Precompile the src force-includes plus defines.h and force-include the
# result into C++ src compiles only; their preprocessed output is unchanged.
# Registration is gated host-side (pch_enabled()). Keep the closure, ccache
# values, and stamp flow in sync with build_helpers/pch.py.
# values, probe flow, stamp flow, and env-knob spellings in sync with
# build_helpers/pch.py.
# Compiler failures that clear on their own must not latch the .failed marker
_TRANSIENT_ERRORS = ("No space left", "Cannot allocate", "Resource temporarily")
# Keep in sync with helpers.TRUTHY_ENV_STRINGS / FALSY_ENV_STRINGS
_TRUTHY = ("1", "true", "yes", "on", "enable")
_FALSY = ("", "0", "false", "no", "off", "disable")
_STRICT_RAW = os.environ.get("ESPHOME_PCH_STRICT")
_STRICT_VALUE = (_STRICT_RAW or "").strip().lower()
_STRICT = _STRICT_VALUE in _TRUTHY
if _STRICT_RAW is not None and _STRICT_VALUE not in _TRUTHY + _FALSY:
# A typo must not silently turn the gate into a no-op
raise RuntimeError(f"Unrecognized ESPHOME_PCH_STRICT={_STRICT_RAW!r}; use 1 or 0")
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
_CORE_HEADER = "esphome/core/defines.h"
@@ -117,26 +128,12 @@ def _compile_gch(cxx, flags, header: Path, gch: Path, proj_dir: Path):
raise OSError(f"compiler killed by signal {-result.returncode}")
if result.returncode != 0:
return result.stderr
# -MF is only legal alongside a dependency flag; pass it solely to
# redirect a depfile that -MD/-MMD in the flags would otherwise write
dep_redirect = (
["-MF", os.devnull]
if any(f in ("-MD", "-MMD", "-M", "-MM") for f in flags)
else []
)
return _probe_gch(cxx, flags, header, proj_dir)
def _probe_run(cxx, flags, extra, proj_dir: Path):
probe = subprocess.run( # noqa: PLW1510
[
cxx,
*flags,
*dep_redirect,
"-Winvalid-pch",
"-include",
str(header),
"-fsyntax-only",
"-x",
"c++",
"-",
],
[cxx, *flags, *extra, "-fsyntax-only", "-x", "c++", "-"],
cwd=proj_dir,
env={**os.environ, "LC_ALL": "C"},
input="",
@@ -145,9 +142,34 @@ def _compile_gch(cxx, flags, header: Path, gch: Path, proj_dir: Path):
)
if probe.returncode < 0:
raise OSError(f"probe killed by signal {-probe.returncode}")
if probe.returncode != 0 or ".gch" in probe.stderr:
return f"toolchain cannot load the pch: {probe.stderr.strip()}"
return None
return probe
def _probe_gch(cxx, flags, header: Path, proj_dir: Path):
"""Load-check an existing .gch; error string or None. Rejection must
be a nonzero exit (keep in sync with pch_probe_args); a baseline run
without the pch keeps environmental failures from being blamed on it."""
# -MF is only legal alongside a dependency flag; pass it solely to
# redirect a depfile that -MD/-MMD in the flags would otherwise write
dep_redirect = (
["-MF", os.devnull]
if any(f in ("-MD", "-MMD", "-M", "-MM") for f in flags)
else []
)
probe = _probe_run(
cxx,
flags,
[*dep_redirect, "-Winvalid-pch", "-Werror=invalid-pch", "-include", str(header)],
proj_dir,
)
if probe.returncode == 0:
return None
baseline = _probe_run(cxx, flags, dep_redirect, proj_dir)
if baseline.returncode != 0:
# Deterministic and latchable; the transient filter at the caller
# keeps resource exhaustion from latching
return f"probe cannot run at all: {baseline.stderr.strip()[:200]}"
return f"toolchain cannot load the pch: {probe.stderr.strip()}"
def _read_stamp(path: Path) -> str:
@@ -158,11 +180,18 @@ def _read_stamp(path: Path) -> str:
return ""
def _setup_pch() -> None:
def _setup_pch() -> bool | None:
if projenv is None:
# Expected under -t nobuild; anything else must leave a trail
print(f"ESPHome: projenv unavailable ({_projenv_error}); skipping pch")
return
try:
from SCons.Script import COMMAND_LINE_TARGETS
except ImportError:
# No SCons is an anomaly under PlatformIO: the unknown state
# must not read as success (strict decides fatality at the gate)
return False
# Expected under -t nobuild (nothing compiles); a missing
# projenv on a real compile must not pass strict
return "nobuild" in [str(t) for t in COMMAND_LINE_TARGETS]
# Project root: SCons compiles run here, so the relative -include
# resolves; an absolute path would break cross-device ccache sharing.
proj_dir = Path(env.subst("$PROJECT_DIR")) # noqa: F821
@@ -286,12 +315,22 @@ def _setup_pch() -> None:
checksum = digest.hexdigest()
# The ccache .sum sidecar doubles as the freshness stamp
if (
not header.is_file()
or not gch.is_file()
or not sum_path.is_file()
or (_read_stamp(sum_path) != checksum)
):
fresh = (
header.is_file()
and gch.is_file()
and sum_path.is_file()
and (_read_stamp(sum_path) == checksum)
)
if fresh and _STRICT:
# Rejection is per-process: strict re-proves a cached .gch loads
# (mirrors the pch_strict() re-probe in build_helpers/pch.py)
error = _probe_gch(cxx, flags, header, proj_dir)
if error is not None:
print(f"ESPHome: {error}")
gch.unlink(missing_ok=True)
sum_path.unlink(missing_ok=True)
return
if not fresh:
failed_marker = Path(f"{gch}.failed")
if _read_stamp(failed_marker) == checksum:
print(
@@ -351,14 +390,27 @@ def _setup_pch() -> None:
# -Wno-error: the per-process probe can pass while a later cc1plus
# rejects the .gch; that must stay a warning under user -Werror.
projenv.Prepend( # noqa: F821
CXXFLAGS=["-Winvalid-pch", "-Wno-error=invalid-pch", "-include", header.name]
CXXFLAGS=[
"-Winvalid-pch",
# Strict inverts: a per-process consumer rejection reds the build
"-Werror=invalid-pch" if _STRICT else "-Wno-error=invalid-pch",
"-include",
header.name,
]
)
projenv["ENV"].update(ccache_updates) # noqa: F821
print("ESPHome: Compiling with precompiled header")
return True
try:
_setup_pch()
_used = _setup_pch()
except Exception: # noqa: BLE001 -- a speedup must never break the build
if _STRICT:
raise
print("ESPHome: pch internal error; compiling without it")
traceback.print_exc()
else:
if _STRICT and not _used:
raise RuntimeError("ESPHOME_PCH_STRICT: precompiled header was not used")
+2 -1
View File
@@ -7,7 +7,7 @@ import re
import time
from esphome import loader
from esphome.build_helpers.pch import PCH_ARTIFACT_NAMES
from esphome.build_helpers.pch import PCH_ARTIFACT_NAMES, PCH_PREFIX_HEADER
from esphome.compiled_config import save_compiled_config
from esphome.config import iter_component_configs, iter_components
from esphome.const import (
@@ -247,6 +247,7 @@ def copy_src_tree():
Path(
"esphome/core/ring_buffer.h"
), # moved to components/ring_buffer/, removed in 2026.11.0
Path(PCH_PREFIX_HEADER), # build machinery, not user API
}
include_l = []
for target, _ in source_files_l:
+5
View File
@@ -543,6 +543,11 @@ _SMOKE_HARNESS_TRIGGER_FILES = frozenset(
ESP32_PLATFORMIO_TRIGGER_FILES = _SMOKE_HARNESS_TRIGGER_FILES | {
"esphome/build_gen/platformio.py",
# The pch machinery the strict smoke job polices, and the modules it
# imports; the rest of build_helpers/ does not affect PlatformIO builds
"esphome/build_helpers/pch.py",
"esphome/build_helpers/ccache.py",
"esphome/build_helpers/idedata.py",
}
+7
View File
@@ -1013,6 +1013,10 @@ _ESP32_PLATFORMIO_FULL_LIST_FILES = [
# PlatformIO subsystem (path-prefix trigger) + build generator
["esphome/platformio/runner.py"],
["esphome/platformio/toolchain.py"],
# The pch modules are standalone triggers, not the whole build_helpers/
["esphome/build_helpers/pch.py"],
["esphome/build_helpers/ccache.py"],
["esphome/build_helpers/idedata.py"],
["esphome/build_gen/platformio.py"],
# Workflow / harness files
["script/test_build_components.py"],
@@ -1071,6 +1075,9 @@ def test_esp32_platformio_components_to_test_returns_full_list_on_infrastructure
# Non-PlatformIO files in esphome/build_gen/ do NOT trigger the
# full list -- only esphome/build_gen/platformio.py is a trigger.
(["esphome/build_gen/espidf.py"], [], []),
# build_helpers modules the pch does not import are not triggers.
(["esphome/build_helpers/size_summary.py"], [], []),
(["esphome/build_helpers/ninja.py"], [], []),
# Docs / unrelated files -> empty.
(["README.md"], [], []),
([], [], []),
@@ -1842,3 +1842,53 @@ def test_write_project_pch_no_device_path_poison(tmp_path: Path) -> None:
(CORE.relative_pioenvs_path(name) / "esphome_pch.h.gch.sum").read_text()
)
assert sums[0] == sums[1]
def test_write_project_pch_strict_raises_on_skip(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
paths = _make_framework(tmp_path)
_set_flags(
"-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", "-include foo.h"
)
with pytest.raises(EsphomeError, match="precedes the pch"):
_write_ninja(paths, ccache="/usr/bin/ccache")
def test_write_project_pch_strict_emits_probe_edge(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Strict mode gates C++ src edges on a hard-failing load probe."""
paths = _make_framework(tmp_path)
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
content = _write_ninja(paths, ccache="/usr/bin/ccache")
assert "pchprobe" not in content
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
content = _write_ninja(paths, ccache="/usr/bin/ccache")
assert "build esphome_pch.probe: pchprobe esphome_pch.h.gch" in content
assert "-Werror=invalid-pch" in content
# $out only expands in rule text; an edge-level binding would emit a
# bare stamp command and fail every strict build
assert "&& $python $buildtool touch $out" in content
assert "$stamp" not in content
# Strict consumers escalate: a per-TU rejection must red the build
assert "srccxxflags = -Winvalid-pch -Werror=invalid-pch" in content
assert "-Wno-error=invalid-pch" not in content
# With extra src flags the probe edge carries them like the .gch edge
CORE.platformio_options["build_src_flags"] = (
"-include esphome/components/esp8266/throw_stubs.h -DSRC_EXTRA"
)
content = _write_ninja(paths, ccache="/usr/bin/ccache")
assert "pchprobe esphome_pch.h.gch\n flags = " in content
edges = [
line
for line in content.splitlines()
if line.startswith("build obj/src/") and ".cpp.o:" in line
]
assert edges
assert all(line.endswith("| esphome_pch.h.gch esphome_pch.probe") for line in edges)
@@ -2,6 +2,7 @@
from __future__ import annotations
import os
from pathlib import Path
import subprocess
import sys
@@ -244,3 +245,20 @@ def test_copy_failure_leaves_no_partial_output(tmp_path: Path) -> None:
):
assert build_tool.main() == 1
assert not dst.exists()
def test_touch_creates_and_updates_stamp(tmp_path: Path) -> None:
stamp = tmp_path / "esphome_pch.probe"
assert build_tool._run_touch(str(stamp)) == 0
assert stamp.is_file()
os.utime(stamp, (1, 1))
assert build_tool._run_touch(str(stamp)) == 0
assert stamp.stat().st_mtime > 1
def test_touch_reports_failure(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
missing_dir = tmp_path / "gone" / "stamp"
assert build_tool._run_touch(str(missing_dir)) == 1
assert "touch:" in capsys.readouterr().err
+921
View File
@@ -4,7 +4,9 @@ from __future__ import annotations
import json
import logging
import os
from pathlib import Path
import subprocess
from unittest.mock import patch
import pytest
@@ -488,3 +490,922 @@ def test_get_component_cmakelists_no_compile_features() -> None:
content = get_component_cmakelists()
assert "target_compile_features" not in content
@pytest.fixture(autouse=True)
def _pch_default_on(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pin the knob so a developer's ESPHOME_PCH_ENABLE=0 cannot fail these."""
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
def _make_pch_device(tmp_path: Path, name: str) -> Path:
"""A device dir with the pch source headers and a stub compile_commands."""
from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS
dev = tmp_path / name
for header in PCH_DEFAULT_HEADERS:
path = dev / "src" / header
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("")
# A real quoted include chain and a per-device-named sdkconfig with
# identical content: the closure and sdkconfig inputs must be exercised
(dev / "src" / "esphome" / "core" / "defines.h").write_text(
'#include "esphome/core/macros.h"\n'
)
(dev / "src" / "esphome" / "core" / "macros.h").write_text("#define M 1\n")
# Both spellings: tests patch CORE.name to "test" or to the device name
(dev / f"sdkconfig.{name}").write_text("CONFIG_X=y\n")
(dev / "sdkconfig.test").write_text("CONFIG_X=y\n")
build = dev / "build"
build.mkdir(exist_ok=True)
from esphome.build_helpers.pch import pch_header_text
(build / "esphome_pch.h").write_text(pch_header_text(PCH_DEFAULT_HEADERS))
# Native separators: mixed f-string paths break the src-prefix match
# on Windows
src_file = str(dev / "src" / "a.cpp")
(build / "compile_commands.json").write_text(
json.dumps(
[
{
"directory": str(build),
"command": (
"g++ -DX=1 -include esphome_pch.h "
"-o esp-idf/src/CMakeFiles/__idf_src.dir/a.cpp.obj "
f'-c "{src_file}"'
),
"file": src_file,
}
]
)
)
return dev
def test_prepare_pch_writes_header_and_sum(tmp_path: Path) -> None:
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_a")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def fake_compile(cmd, **kwargs):
if "-fsyntax-only" in cmd:
# The load probe follows a successful .gch build
return subprocess.CompletedProcess(cmd, 0, "", "")
# The compile must target the header, not the stub TU
assert cmd[-5:-3] == ["c++-header", "-c"]
gch.write_bytes(b"gch")
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
):
prepare_pch()
checksum = (dev / "build" / "esphome_pch.h.gch.sum").read_text().strip()
assert len(checksum) == 64
# Unchanged inputs: the second call must not recompile
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
):
prepare_pch()
def test_pch_no_device_path_poison(tmp_path: Path) -> None:
"""Regression: neither the injected -include nor the .sum may carry the
per-device build path, or cross-device ccache sharing breaks."""
from esphome.build_gen.espidf import get_component_cmakelists, prepare_pch
sums = []
for name in ("dev_a", "dev_b"):
dev = _make_pch_device(tmp_path, name)
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def fake_compile(cmd, _gch=gch, **kwargs):
_gch.write_bytes(b"gch")
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", name),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
):
prepare_pch()
content = get_component_cmakelists()
assert str(dev) not in content
sums.append((dev / "build" / "esphome_pch.h.gch.sum").read_text())
assert sums[0] == sums[1]
def test_component_cmakelists_pch_block(monkeypatch: pytest.MonkeyPatch) -> None:
from esphome.build_gen.espidf import get_component_cmakelists
content = get_component_cmakelists()
assert '"$<$<COMPILE_LANGUAGE:CXX>:-include>"' in content
assert "-Wno-error=invalid-pch" in content
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
strict_content = get_component_cmakelists()
assert "-Werror=invalid-pch" in strict_content
monkeypatch.delenv("ESPHOME_PCH_STRICT")
assert '"$<$<COMPILE_LANGUAGE:CXX>:esphome_pch.h>"' in content
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
assert "-include" not in get_component_cmakelists()
def test_pch_compile_command_variants(tmp_path: Path) -> None:
"""Missing DB, no matching entry, and launcher-prefixed commands."""
from esphome.build_helpers.pch import pch_compile_command
build = tmp_path / "build"
build.mkdir()
header = build / "esphome_pch.h"
gch = build / "esphome_pch.h.gch"
assert pch_compile_command(build, header, gch) is None
(build / "compile_commands.json").write_text(
json.dumps(
[
{"command": "gcc -c other.c", "file": "other.c"},
]
)
)
assert pch_compile_command(build, header, gch) is None
src_file = str(tmp_path / "src" / "esphome" / "a.cpp")
(build / "compile_commands.json").write_text(
json.dumps(
[
{
"command": (
"/usr/bin/ccache g++ -DX=1 -include esphome_pch.h -MMD "
"-MT a.cpp.obj -MF a.cpp.obj.d "
"-o esp-idf/src/CMakeFiles/__idf_src.dir/a.cpp.obj "
f"-c {src_file}"
),
"file": src_file,
},
]
)
)
# Launcher stripped; -include/-o/-c and depfile flags removed
cmd, cmd_dir = pch_compile_command(build, header, gch)
assert cmd == [
"g++",
"-DX=1",
"-x",
"c++-header",
"-c",
str(header),
"-o",
str(gch),
]
# The compile must run where the flags were resolved
assert cmd_dir == build
@pytest.mark.skipif(os.name == "nt", reason="symlinks need privileges on Windows")
def test_pch_compile_command_matches_src_through_symlink(tmp_path: Path) -> None:
"""Find the src TU when CMake spells paths through a different symlink (macOS /tmp)."""
from esphome.build_helpers.pch import pch_compile_command
real = tmp_path / "real"
(real / "src" / "esphome").mkdir(parents=True)
link = tmp_path / "link"
link.symlink_to(real, target_is_directory=True)
CORE.build_path = str(real)
build = real / "build"
build.mkdir()
header = build / "esphome_pch.h"
gch = build / "esphome_pch.h.gch"
src_file = str(link / "src" / "esphome" / "a.cpp")
(build / "compile_commands.json").write_text(
json.dumps([{"command": f"g++ -DX=1 -o a.obj -c {src_file}", "file": src_file}])
)
cmd, cmd_dir = pch_compile_command(build, header, gch)
assert cmd[:2] == ["g++", "-DX=1"]
assert cmd_dir == build
def test_pch_compile_command_rejects_unusable_entries(tmp_path: Path) -> None:
"""Malformed DB shapes and command-less entries skip cleanly instead of
producing a compiler-less argv retried every build."""
from esphome.build_helpers.pch import pch_compile_command
build = tmp_path / "build"
build.mkdir()
header = build / "esphome_pch.h"
gch = build / "esphome_pch.h.gch"
db = build / "compile_commands.json"
src_file = str(tmp_path / "src" / "esphome" / "a.cpp")
db.write_text(json.dumps({"not": "a list"}))
assert pch_compile_command(build, header, gch) is None
db.write_text(json.dumps(["just a string"]))
assert pch_compile_command(build, header, gch) is None
# An empty-string directory must fall back to the build dir, not cwd
db.write_text(
json.dumps(
[
{
"directory": "",
"command": f"g++ -DX=1 -o a.obj -c {src_file}",
"file": src_file,
}
]
)
)
_, cmd_dir = pch_compile_command(build, header, gch)
assert cmd_dir == build
# Corrupted entries with null fields must skip, not raise
db.write_text(
json.dumps(
[
{"file": None, "command": "g++ -c x.cpp", "directory": None},
{"file": src_file, "command": None, "directory": None},
]
)
)
assert pch_compile_command(build, header, gch) is None
# arguments-style entry (allowed by the spec, unused by CMake)
db.write_text(
json.dumps([{"arguments": ["g++", "-c", src_file], "file": src_file}])
)
assert pch_compile_command(build, header, gch) is None
def test_pch_header_list_order_is_in_checksum(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Reordering PCH_DEFAULT_HEADERS keeps the include closure identical, but the
generated header text differs, so the .gch must rebuild."""
import esphome.build_gen.espidf as espidf_mod
dev = _make_pch_device(tmp_path, "dev_r")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def fake_compile(cmd, **kwargs):
gch.write_bytes(b"gch")
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
):
espidf_mod.prepare_pch()
first = (dev / "build" / "esphome_pch.h.gch.sum").read_text()
monkeypatch.setattr(
espidf_mod,
"PCH_DEFAULT_HEADERS",
tuple(reversed(espidf_mod.PCH_DEFAULT_HEADERS)),
)
espidf_mod.prepare_pch()
assert (dev / "build" / "esphome_pch.h.gch.sum").read_text() != first
def test_prepare_pch_failure_writes_marker_and_skips_retry(tmp_path: Path) -> None:
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_f")
CORE.build_path = dev
calls = []
def failing_compile(cmd, **kwargs):
calls.append(cmd)
return subprocess.CompletedProcess(cmd, 1, "", "boom")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=failing_compile),
):
prepare_pch()
prepare_pch()
assert len(calls) == 1
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
assert (dev / "build" / "esphome_pch.h.gch.failed").exists()
def test_prepare_pch_spawn_oserror_is_transient(tmp_path: Path) -> None:
"""Spawn/IO failures retry on the next build instead of latching."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_o")
CORE.build_path = dev
calls = []
def raising(cmd, **kwargs):
calls.append(cmd)
raise OSError("no such compiler")
header = dev / "build" / "esphome_pch.h"
before = header.stat().st_mtime_ns
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=raising),
):
prepare_pch()
prepare_pch()
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
assert len(calls) == 2
# No .gch was ever in play, so the header must not be re-touched into
# forcing a full rebuild on every failing build
assert header.stat().st_mtime_ns == before
def test_prepare_pch_transient_with_stale_gch_bumps_header(tmp_path: Path) -> None:
"""A stale .gch removed on a transient failure must dirty its consumers."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_s")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
gch.write_bytes(b"stale")
header = dev / "build" / "esphome_pch.h"
os.utime(header, (1, 1))
with (
patch.object(CORE, "name", "test"),
patch(
"esphome.build_helpers.pch.subprocess.run",
side_effect=OSError("no such compiler"),
),
):
prepare_pch()
assert not gch.exists()
assert header.stat().st_mtime_ns > 1_000_000_000
def test_prepare_pch_disabled_discards_and_skips_compile(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The escape hatch is self-cleaning: a leftover .gch is removed."""
from esphome.build_gen.espidf import prepare_pch
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
dev = _make_pch_device(tmp_path, "dev_d")
CORE.build_path = dev
stale = dev / "build" / "esphome_pch.h.gch"
stale.write_bytes(b"stale")
with patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError):
prepare_pch()
assert not stale.exists()
def test_prepare_pch_missing_sdkconfig_fails_closed(tmp_path: Path) -> None:
"""No sdkconfig means no config identity for the .sum: no pch at all."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_m")
(dev / "sdkconfig.test").unlink()
CORE.build_path = dev
stale = dev / "build" / "esphome_pch.h.gch"
stale.write_bytes(b"stale")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
):
prepare_pch()
assert not stale.exists()
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
def test_prepare_pch_signal_kill_is_transient(tmp_path: Path) -> None:
"""A signal-killed compile (OOM) must not latch the .failed marker."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_k")
CORE.build_path = dev
calls = []
def killed(cmd, **kwargs):
calls.append(cmd)
return subprocess.CompletedProcess(cmd, -9, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=killed),
):
prepare_pch()
prepare_pch()
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
assert len(calls) == 2
def test_prepare_pch_probe_spawn_failure_degrades(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A probe that cannot run discards the pch; strict raises."""
from esphome.build_gen.espidf import prepare_pch
from esphome.core import EsphomeError
dev = _make_pch_device(tmp_path, "dev_pf")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def probe_dies(cmd, **kwargs):
if "-fsyntax-only" in cmd:
raise OSError("probe spawn failed")
gch.write_bytes(b"gch")
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=probe_dies),
):
prepare_pch()
assert not gch.exists()
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=probe_dies),
pytest.raises(EsphomeError, match="probe did not run"),
):
prepare_pch()
@pytest.mark.parametrize(
("stderr", "code"),
[("", -9), ("fatal: No space left on device", 1)],
ids=("signal-kill", "enospc"),
)
def test_prepare_pch_probe_environmental_failures_do_not_latch(
tmp_path: Path, stderr: str, code: int
) -> None:
"""A signal-killed or ENOSPC probe retries next build, no marker."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_pe")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def env_probe(cmd, **kwargs):
if "-fsyntax-only" in cmd:
return subprocess.CompletedProcess(cmd, code, "", stderr)
gch.write_bytes(b"gch")
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=env_probe),
):
prepare_pch()
assert not gch.exists()
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
def test_prepare_pch_signal_kill_strict_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from esphome.build_gen.espidf import prepare_pch
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
dev = _make_pch_device(tmp_path, "dev_ks")
CORE.build_path = dev
with (
patch.object(CORE, "name", "test"),
patch(
"esphome.build_helpers.pch.subprocess.run",
side_effect=lambda cmd, **kw: subprocess.CompletedProcess(cmd, -9, "", ""),
),
pytest.raises(EsphomeError, match="killed by signal"),
):
prepare_pch()
def test_prepare_pch_strict_raises_when_disabled(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Strict must not read a disabled pch as success."""
from esphome.build_gen.espidf import prepare_pch
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
dev = _make_pch_device(tmp_path, "dev_ds")
CORE.build_path = dev
with (
patch.object(CORE, "name", "test"),
pytest.raises(EsphomeError, match="disabled"),
):
prepare_pch()
def test_prepare_pch_missing_sdkconfig_strict_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from esphome.build_gen.espidf import prepare_pch
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
dev = _make_pch_device(tmp_path, "dev_ss")
(dev / "sdkconfig.test").unlink()
CORE.build_path = dev
with (
patch.object(CORE, "name", "test"),
pytest.raises(EsphomeError, match="sdkconfig unreadable"),
):
prepare_pch()
def test_prepare_pch_without_compile_commands(tmp_path: Path) -> None:
"""Stale checksum but no configured TU yet: no compile, no sidecars."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_n")
(dev / "build" / "compile_commands.json").unlink()
CORE.build_path = dev
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
):
prepare_pch()
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
def test_write_project_pch_disabled_writes_no_header(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from esphome.build_gen.espidf import write_project
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
_write_project_description(tmp_path, {})
CORE.build_path = tmp_path
with (
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
patch.object(CORE, "name", "test"),
):
write_project()
assert not (tmp_path / "build" / "esphome_pch.h").exists()
def test_write_project_writes_pch_header(tmp_path: Path) -> None:
"""The header write_project emits is what _pch_cmake() force-includes;
this pairing is the one non-fail-safe path in the design."""
from esphome.build_gen.espidf import write_project
from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS, pch_header_text
_write_project_description(tmp_path, {})
CORE.build_path = tmp_path
with (
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
patch.object(CORE, "name", "test"),
):
write_project()
assert (tmp_path / "build" / "esphome_pch.h").read_text() == pch_header_text(
PCH_DEFAULT_HEADERS
)
def test_prepare_pch_stale_bailout_removes_gch(tmp_path: Path) -> None:
"""A stale .gch must not survive when no compile command is available."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_s")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
gch.write_bytes(b"stale")
(dev / "build" / "esphome_pch.h.gch.sum").write_text("stale-sum\n")
(dev / "build" / "compile_commands.json").unlink()
with patch.object(CORE, "name", "test"):
prepare_pch()
assert not gch.exists()
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
def test_prepare_pch_zero_exit_without_gch_is_failure(tmp_path: Path) -> None:
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_z")
CORE.build_path = dev
def no_output(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=no_output),
):
prepare_pch()
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
assert (dev / "build" / "esphome_pch.h.gch.failed").exists()
def test_prepare_pch_bumps_header_for_object_depends(tmp_path: Path) -> None:
"""The OBJECT_DEPENDS edge watches the header; a rebuilt .gch must bump
it so pch-consuming TUs recompile."""
import os as _os
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_t")
CORE.build_path = dev
header = dev / "build" / "esphome_pch.h"
gch = dev / "build" / "esphome_pch.h.gch"
_os.utime(header, (0, 0))
before = header.stat().st_mtime
def fake_compile(cmd, **kwargs):
gch.write_bytes(b"gch")
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
):
prepare_pch()
assert header.stat().st_mtime > before
def test_component_cmakelists_pch_object_depends() -> None:
from esphome.build_gen.espidf import get_component_cmakelists
content = get_component_cmakelists()
assert 'OBJECT_DEPENDS "${CMAKE_BINARY_DIR}/esphome_pch.h"' in content
def test_prepare_pch_command_change_invalidates_sum(tmp_path: Path) -> None:
"""A flag-only change in the compile DB must rebuild the .gch."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_c")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def fake_compile(cmd, **kwargs):
gch.write_bytes(b"gch")
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
):
prepare_pch()
first = (dev / "build" / "esphome_pch.h.gch.sum").read_text()
db = dev / "build" / "compile_commands.json"
db.write_text(db.read_text().replace("-DX=1", "-DX=2"))
prepare_pch()
assert (dev / "build" / "esphome_pch.h.gch.sum").read_text() != first
def test_prepare_pch_keeps_user_force_includes(tmp_path: Path) -> None:
from esphome.build_helpers.pch import pch_compile_command
dev = _make_pch_device(tmp_path, "dev_u")
CORE.build_path = dev
build = dev / "build"
src_file = str(dev / "src" / "esphome" / "a.cpp")
build.joinpath("compile_commands.json").write_text(
json.dumps(
[
{
"directory": str(build),
"command": (
"g++ -include user.h -include esphome_pch.h "
f"-o a.obj -c {src_file}"
),
"file": src_file,
}
]
)
)
cmd, _ = pch_compile_command(build, build / "esphome_pch.h", build / "x.gch")
assert "user.h" in cmd
assert "esphome_pch.h" not in " ".join(cmd[:-3])
def test_prepare_pch_identity_unknown_discards(tmp_path: Path) -> None:
"""An OSError from the checksum discards artifacts and skips the pch."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_i")
CORE.build_path = dev
stale = dev / "build" / "esphome_pch.h.gch"
stale.write_bytes(b"stale")
with (
patch.object(CORE, "name", "test"),
patch(
"esphome.build_helpers.pch.pch_checksum",
side_effect=OSError("stat failed"),
),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
):
prepare_pch()
assert not stale.exists()
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
def test_prepare_pch_transient_compiler_failure_does_not_latch(
tmp_path: Path,
) -> None:
"""ENOSPC-style failures clear on their own; no .failed marker."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_e")
CORE.build_path = dev
calls = []
def enospc(cmd, **kwargs):
calls.append(cmd)
return subprocess.CompletedProcess(
cmd, 1, "", "fatal error: No space left on device"
)
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=enospc),
):
prepare_pch()
prepare_pch()
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
assert len(calls) == 2
def test_prepare_pch_strict_raises_on_missing_db(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""ESPHOME_PCH_STRICT turns the silent skip into a failure."""
from esphome.build_gen.espidf import prepare_pch
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
dev = _make_pch_device(tmp_path, "dev_st")
(dev / "build" / "compile_commands.json").unlink()
CORE.build_path = dev
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
pytest.raises(EsphomeError, match="no usable compile command"),
):
prepare_pch()
def test_prepare_pch_probe_rejection_latches_and_degrades(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A toolchain that cannot load its own .gch discards it, latches the
marker, and fails strict mode."""
from esphome.build_gen.espidf import prepare_pch
from esphome.core import EsphomeError
dev = _make_pch_device(tmp_path, "dev_p")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def rejecting(cmd, **kwargs):
if "-fsyntax-only" in cmd:
if "-include" not in cmd:
# Baseline without the pch passes: the pch is to blame
return subprocess.CompletedProcess(cmd, 0, "", "")
return subprocess.CompletedProcess(
cmd, 1, "", "error: esphome_pch.h.gch: had text segment "
)
gch.write_bytes(b"gch")
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=rejecting),
):
prepare_pch()
assert not gch.exists()
assert (dev / "build" / "esphome_pch.h.gch.failed").exists()
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
(dev / "build" / "esphome_pch.h.gch.failed").unlink()
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=rejecting),
pytest.raises(EsphomeError, match="cannot load the pch"),
):
prepare_pch()
def test_prepare_pch_strict_reprobes_cached_gch(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Rejection is per-process: strict must re-prove a cached .gch loads."""
from esphome.build_gen.espidf import prepare_pch
from esphome.core import EsphomeError
dev = _make_pch_device(tmp_path, "dev_rc")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def ok(cmd, **kwargs):
if "-fsyntax-only" not in cmd:
gch.write_bytes(b"gch")
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=ok),
):
prepare_pch()
assert gch.exists()
def reject(cmd, **kwargs):
assert "-fsyntax-only" in cmd, "cached path must not recompile"
if "-include" not in cmd:
return subprocess.CompletedProcess(cmd, 0, "", "")
return subprocess.CompletedProcess(
cmd, 1, "", "error: esphome_pch.h.gch: had text segment "
)
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=reject),
pytest.raises(EsphomeError, match="cannot load the pch"),
):
prepare_pch()
assert not gch.exists()
# Per-process rejection may not reproduce: the cached path must not
# latch the pch off for later non-strict builds
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
def test_prepare_pch_unexpected_command_shape_degrades(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A tail the probe slice cannot trust discards and degrades."""
from esphome.build_gen.espidf import prepare_pch
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
dev = _make_pch_device(tmp_path, "dev_sh")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def ok(cmd, **kwargs):
gch.write_bytes(b"gch")
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=ok),
patch(
"esphome.build_helpers.pch.pch_compile_command",
return_value=(
["g++", "-DX=1", "-c", "x", "-o", "y", "extra"],
dev / "build",
),
),
pytest.raises(EsphomeError, match="command shape"),
):
prepare_pch()
assert not gch.exists()
# Non-strict: same shape problem degrades without raising
monkeypatch.delenv("ESPHOME_PCH_STRICT")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=ok),
patch(
"esphome.build_helpers.pch.pch_compile_command",
return_value=(
["g++", "-DX=1", "-c", "x", "-o", "y", "extra"],
dev / "build",
),
),
):
prepare_pch()
assert not gch.exists()
def test_prepare_pch_probe_baseline_spawn_failure_is_transient(
tmp_path: Path,
) -> None:
"""A baseline that cannot spawn is environmental: no marker."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_bs")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def flaky(cmd, **kwargs):
if "-fsyntax-only" in cmd:
if "-include" in cmd:
return subprocess.CompletedProcess(cmd, 1, "", "boom")
raise OSError("baseline spawn failed")
gch.write_bytes(b"gch")
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=flaky),
):
prepare_pch()
assert not gch.exists()
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
@@ -185,7 +185,7 @@ def test_expand_response_files(tmp_path: Path) -> None:
rsp = tmp_path / "flags.rsp"
rsp.write_text("-DFROM_RSP -I/rsp/inc")
tokens = idedata._expand_response_files(
tokens = idedata.expand_response_files(
["g++", f"@{rsp.name}", "-c", "x.cpp"], tmp_path
)
@@ -196,7 +196,7 @@ def test_expand_response_files(tmp_path: Path) -> None:
def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None:
"""An unreadable ``@file`` token is kept verbatim rather than dropped."""
tokens = idedata._expand_response_files(["g++", "@nope.rsp"], tmp_path)
tokens = idedata.expand_response_files(["g++", "@nope.rsp"], tmp_path)
assert "@nope.rsp" in tokens
@@ -408,7 +408,7 @@ def test_split_command_preserves_paths_and_unescapes_quotes() -> None:
r"""Backslash paths survive while ``\"`` define-quoting is unescaped."""
command = r"C:\esp\bin\riscv32-esp-elf-g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp"
tokens = idedata._split_command(command)
tokens = idedata.split_command(command)
assert tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe"
assert '-DVER="1.2.3"' in tokens
@@ -422,8 +422,8 @@ def test_split_command_empty_returns_empty() -> None:
Guards against ``CommandLineToArgvW("")`` returning the current process name
instead of an empty list.
"""
assert idedata._split_command("") == []
assert idedata._split_command(" ") == []
assert idedata.split_command("") == []
assert idedata.split_command(" ") == []
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
@@ -584,9 +584,9 @@ def test_load_or_build_idedata_rebuilds_non_dict_cache(tmp_path: Path) -> None:
def test_is_launcher_matches_only_known_launchers() -> None:
"""Compilers of any shape pass; only the closed launcher set matches."""
for token in ("/t/g++-13", "gcc-8.4.0", "clang++-17", "armcc", "icx", "cc"):
assert not idedata._is_launcher(token)
assert not idedata.is_launcher(token)
for token in ("/opt/homebrew/bin/ccache", "CCACHE.EXE", "distcc", "sccache"):
assert idedata._is_launcher(token)
assert idedata.is_launcher(token)
def test_load_or_build_idedata_corrupted_cache_is_logged(
+163
View File
@@ -216,3 +216,166 @@ def test_ccache_pch_env_warns_on_falsy_extsum(
env = pch.ccache_pch_env()
assert "CCACHE_PCH_EXTSUM" not in env
assert "disables pch caching" in caplog.text
@pytest.mark.parametrize(
("value", "expected"),
[(None, False), ("0", False), ("1", True), ("true", True)],
)
def test_pch_strict(
value: str | None, expected: bool, monkeypatch: pytest.MonkeyPatch
) -> None:
if value is None:
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
else:
monkeypatch.setenv("ESPHOME_PCH_STRICT", value)
assert pch.pch_strict() is expected
def test_pch_cmake_consumer_substitutes_target_and_sources(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False)
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
block = pch.pch_cmake_consumer("app", "${APP_SOURCES}")
assert "target_compile_options(app PRIVATE" in block
assert '"$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"' in block
assert "-Wno-error=invalid-pch" in block
assert '"$<$<COMPILE_LANGUAGE:CXX>:esphome_pch.h>"' in block
assert "set_source_files_properties(${APP_SOURCES} PROPERTIES" in block
assert 'OBJECT_DEPENDS "${CMAKE_BINARY_DIR}/esphome_pch.h"' in block
# Placeholder guard: survives a build-system-side pristine wipe
assert 'file(TOUCH "${CMAKE_BINARY_DIR}/esphome_pch.h")' in block
def test_pch_cmake_consumer_strict_escalates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False)
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
assert "-Werror=invalid-pch" in pch.pch_cmake_consumer("app", "${APP_SOURCES}")
def test_pch_cmake_consumer_empty_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
assert pch.pch_cmake_consumer("app", "${APP_SOURCES}") == ""
def test_guarded_prepare_logs_placeholder_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A failed placeholder touch must be traceable, not silent."""
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
def boom() -> None:
raise RuntimeError("boom")
# Missing build dir: the touch raises and only warns
pch.guarded_prepare(tmp_path / "missing", boom)
assert "Could not create the pch placeholder" in caplog.text
def test_pch_degraded_raises_only_in_strict(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from esphome.core import EsphomeError
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
pch.pch_degraded("reason")
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
with pytest.raises(EsphomeError, match="reason"):
pch.pch_degraded("reason")
def test_pch_extra_scripts_strict_raises_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
with pytest.raises(EsphomeError, match="disabled"):
pch.pch_extra_scripts()
def test_pch_strict_rejects_unrecognized_values(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A typo must not silently disable the gate."""
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_STRICT", "yolo")
with pytest.raises(EsphomeError, match="Unrecognized ESPHOME_PCH_STRICT"):
pch.pch_strict()
def test_discard_pch_raises_when_gch_survives(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A .gch an unlink failure leaves behind would be consumed silently."""
from pathlib import Path as _P
from esphome.core import EsphomeError
(tmp_path / "esphome_pch.h").write_text("")
gch = tmp_path / "esphome_pch.h.gch"
gch.write_bytes(b"gch")
real_unlink = _P.unlink
def failing_unlink(self, missing_ok=False):
if self.name.endswith(".gch"):
raise OSError("readonly")
return real_unlink(self, missing_ok=missing_ok)
monkeypatch.setattr(_P, "unlink", failing_unlink)
with pytest.raises(EsphomeError, match="Could not discard"):
pch.discard_pch(tmp_path)
def test_discard_pch_raises_when_sum_survives(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The .sum is ccache's pch identity; one that survives is as unsafe
as a surviving .gch."""
from pathlib import Path as _P
from esphome.core import EsphomeError
(tmp_path / "esphome_pch.h").write_text("")
(tmp_path / "esphome_pch.h.gch").write_bytes(b"gch")
(tmp_path / "esphome_pch.h.gch.sum").write_text("x")
real_unlink = _P.unlink
def failing_unlink(self, missing_ok=False):
if self.name.endswith(".sum"):
raise OSError("readonly")
return real_unlink(self, missing_ok=missing_ok)
monkeypatch.setattr(_P, "unlink", failing_unlink)
with pytest.raises(EsphomeError, match="Could not discard"):
pch.discard_pch(tmp_path)
def test_discard_pch_warns_when_file_vanished_concurrently(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""An unlink error on a file that is nonetheless gone only warns."""
from pathlib import Path as _P
(tmp_path / "esphome_pch.h").write_text("")
(tmp_path / "esphome_pch.h.gch").write_bytes(b"gch")
real_unlink = _P.unlink
def racing_unlink(self, missing_ok=False):
if self.name.endswith(".sum"):
# Racer removed it, then our unlink errored
raise OSError("stale handle")
return real_unlink(self, missing_ok=missing_ok)
monkeypatch.setattr(_P, "unlink", racing_unlink)
pch.discard_pch(tmp_path)
assert "Could not discard the pch sidecars" in caplog.text
@@ -0,0 +1,43 @@
"""The pch script must reach PlatformIO builds only; the native ESP-IDF
toolchain has its own pch flow in build_gen/espidf.py."""
from pathlib import Path
from unittest.mock import patch
import pytest
from esphome.components import esp32
from esphome.const import Toolchain
from esphome.core import CORE
@pytest.mark.parametrize(
("toolchain", "copied"),
[(Toolchain.PLATFORMIO, True), (Toolchain.ESP_IDF, False)],
)
def test_copy_files_gates_pch_script_on_toolchain(
toolchain: Toolchain, copied: bool, tmp_path: Path
) -> None:
CORE.toolchain = toolchain
CORE.build_path = tmp_path
from esphome.components.esp32 import (
KEY_ESP32,
KEY_EXTRA_BUILD_FILES,
KEY_FLASH_SIZE,
)
with (
patch.object(esp32, "_write_sdkconfig") as write_sdkconfig,
patch.object(esp32, "_write_idf_component_yml"),
patch.object(esp32, "copy_pch_script") as copy_script,
patch.object(esp32, "write_file_if_changed"),
patch.object(esp32, "get_partition_csv"),
patch.dict(
CORE.data,
{KEY_ESP32: {KEY_EXTRA_BUILD_FILES: {}, KEY_FLASH_SIZE: "4MB"}},
),
):
esp32.copy_files()
# Proves execution reached (and passed) the gate on both rows
assert write_sdkconfig.called
assert copy_script.called is copied
@@ -0,0 +1,322 @@
"""nrf52 sdk-nrf pch wiring: the CMake consumer block, the prepare wrapper,
and the two-phase west split in run_compile."""
from collections.abc import Generator
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from esphome.components import nrf52
from esphome.components.zephyr.const import KEY_BOARD
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain
from esphome.core import CORE, EsphomeError
@pytest.fixture(autouse=True)
def pch_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False)
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
@pytest.fixture
def build_dir(tmp_path: Path) -> Path:
d = tmp_path / "build" / ".pioenvs" / "livingroom"
d.mkdir(parents=True)
return d
_AUTOCONF_TEXT = "#define CONFIG_GPIO 1\n"
def _write_autoconf(build_dir: Path) -> Path:
autoconf = build_dir / "zephyr" / "include" / "generated" / "zephyr" / "autoconf.h"
autoconf.parent.mkdir(parents=True)
autoconf.write_text(_AUTOCONF_TEXT)
return autoconf
def test_prepare_pch_disabled_discards_and_degrades(
monkeypatch: pytest.MonkeyPatch, build_dir: Path
) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
gch = build_dir / "esphome_pch.h.gch"
gch.write_bytes(b"x")
nrf52._prepare_pch(build_dir)
assert not gch.exists()
def test_prepare_pch_disabled_strict_raises(
monkeypatch: pytest.MonkeyPatch, build_dir: Path
) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
with pytest.raises(EsphomeError, match="ESPHOME_PCH_STRICT"):
nrf52._prepare_pch(build_dir)
def test_prepare_pch_missing_autoconf_degrades(
build_dir: Path, caplog: pytest.LogCaptureFixture
) -> None:
with patch.object(nrf52.pch, "prepare_pch") as prepare:
nrf52._prepare_pch(build_dir)
assert not prepare.called
assert "No autoconf.h found" in caplog.text
# The header is written first so OBJECT_DEPENDS stays satisfied
assert (build_dir / "esphome_pch.h").is_file()
def test_prepare_pch_missing_autoconf_strict_raises(
monkeypatch: pytest.MonkeyPatch, build_dir: Path
) -> None:
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
with pytest.raises(EsphomeError, match="autoconf.h missing"):
nrf52._prepare_pch(build_dir)
def test_prepare_pch_unreadable_autoconf_fails_closed(
build_dir: Path, caplog: pytest.LogCaptureFixture
) -> None:
# A directory named autoconf.h: read_text raises OSError
autoconf = build_dir / "zephyr" / "include" / "generated" / "autoconf.h"
autoconf.mkdir(parents=True)
with patch.object(nrf52.pch, "prepare_pch") as prepare:
nrf52._prepare_pch(build_dir)
assert not prepare.called
assert "Could not read" in caplog.text
def test_app_build_dir_sysbuild_layout(build_dir: Path) -> None:
app = build_dir / "zephyr"
app.mkdir()
(app / "CMakeCache.txt").write_text("")
assert nrf52._app_build_dir(build_dir) == app
def test_app_build_dir_top_level_layout(build_dir: Path) -> None:
# Non-sysbuild: build_dir/zephyr is the Zephyr output dir, no cache
(build_dir / "zephyr").mkdir()
assert nrf52._app_build_dir(build_dir) == build_dir
def test_app_build_dir_ignores_cache_directory(build_dir: Path) -> None:
(build_dir / "zephyr" / "CMakeCache.txt").mkdir(parents=True)
assert nrf52._app_build_dir(build_dir) == build_dir
def test_app_build_dir_propagates_stat_errors(build_dir: Path) -> None:
# is_file() would swallow this and mislocate the pch
with (
patch.object(Path, "stat", side_effect=PermissionError("denied")),
pytest.raises(PermissionError),
):
nrf52._app_build_dir(build_dir)
def test_prepare_pch_extras_carry_build_identity(build_dir: Path) -> None:
_write_autoconf(build_dir)
with (
patch.dict(CORE.data, {KEY_CORE: {KEY_FRAMEWORK_VERSION: "2.9.2"}}),
patch.object(
nrf52, "zephyr_data", return_value={KEY_BOARD: "adafruit_feather"}
),
patch.object(nrf52, "get_project_compile_flags", return_value=["-Os"]),
patch.object(nrf52.pch, "prepare_pch") as prepare,
):
nrf52._prepare_pch(build_dir)
assert (build_dir / "esphome_pch.h").is_file()
(passed_dir, headers, extras) = prepare.call_args.args
assert passed_dir == build_dir
assert headers == nrf52.PCH_DEFAULT_HEADERS
assert list(extras) == ["2.9.2", "adafruit_feather", _AUTOCONF_TEXT, "-Os"]
def _generate_cmake(tmp_path: Path) -> str:
CORE.config_path = tmp_path / "test.yaml"
CORE.build_path = tmp_path / "build"
CORE.name = "livingroom"
with (
patch(
"esphome.components.zephyr.library.generate_zephyr_modules",
return_value=[],
),
patch.object(nrf52, "get_project_compile_flags", return_value=["-Os"]),
patch.object(nrf52, "get_project_link_flags", return_value=[]),
):
nrf52._generate_cmake_lists()
return (tmp_path / "build" / "zephyr" / "CMakeLists.txt").read_text()
def test_cmake_lists_include_pch_consumer_block(tmp_path: Path) -> None:
# Content contract is pinned by the shared pch_cmake_consumer tests;
# here only that the block reaches the generated CMakeLists
text = _generate_cmake(tmp_path)
assert "target_compile_options(app PRIVATE" in text
assert 'OBJECT_DEPENDS "${CMAKE_BINARY_DIR}/esphome_pch.h"' in text
def test_cmake_lists_pch_block_disabled(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
assert "esphome_pch.h" not in _generate_cmake(tmp_path)
CompileCtx = tuple[Mock, Mock, Path]
class TestRunCompilePhases:
"""The pch pre-build block in run_compile: header write, conditional
cmake-only phase, and the never-abort-the-build exception contract."""
@pytest.fixture
def compile_ctx(self, tmp_path: Path) -> Generator[CompileCtx, None, None]:
CORE.config_path = tmp_path / "test.yaml"
CORE.build_path = tmp_path / "build"
CORE.name = "livingroom"
CORE.toolchain = Toolchain.SDK_NRF
with (
patch.object(nrf52, "check_and_install"),
patch.object(nrf52, "_generate_cmake_lists", return_value=False),
patch.object(
nrf52,
"get_build_paths",
return_value={
"python_executable": "python3",
"framework_path": tmp_path,
},
),
patch.object(nrf52, "get_build_env", return_value={}),
patch.object(nrf52, "zephyr_data", return_value={KEY_BOARD: "board"}),
patch.object(nrf52, "run_command_ok") as run_cmd,
patch.object(nrf52, "_prepare_pch") as prepare,
):
yield run_cmd, prepare, CORE.relative_pioenvs_path(CORE.name)
def _run(self) -> None:
nrf52.run_compile(None, {})
def test_missing_db_runs_cmake_phase(self, compile_ctx: CompileCtx) -> None:
run_cmd, prepare, build_dir = compile_ctx
# cmake-only ok, generated headers ok, final build fails
results = iter([True, True, False])
def west(cmd, **kwargs):
# Phase 1 configures the sysbuild app domain
app = build_dir / "zephyr"
app.mkdir(parents=True, exist_ok=True)
(app / "CMakeCache.txt").write_text("")
return next(results)
run_cmd.side_effect = west
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
self._run()
assert "--cmake-only" in run_cmd.call_args_list[0].args[0]
# Generated syscall headers are built in the app domain pre-pch
headers_cmd = run_cmd.call_args_list[1].args[0]
assert headers_cmd[:2] == ["cmake", "--build"]
assert str(build_dir / "zephyr") in headers_cmd
assert "zephyr_generated_headers" in headers_cmd
assert "--cmake-only" not in run_cmd.call_args_list[2].args[0]
# The pch is prepared in the app domain dir, not the sysbuild root
assert prepare.call_args.args[0] == build_dir / "zephyr"
def test_generated_headers_failure_degrades(
self, compile_ctx, caplog: pytest.LogCaptureFixture
) -> None:
run_cmd, prepare, _ = compile_ctx
# headers target fails, the real build still runs (and fails here)
run_cmd.side_effect = [True, False, False]
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
self._run()
assert "Zephyr header generation failed" in caplog.text
# The doomed .gch compile is skipped: it would latch .gch.failed
assert not prepare.called
def test_generated_headers_failure_strict_raises(
self, monkeypatch: pytest.MonkeyPatch, compile_ctx: CompileCtx
) -> None:
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
run_cmd, prepare, _ = compile_ctx
run_cmd.side_effect = [True, False]
with pytest.raises(EsphomeError, match="ESPHOME_PCH_STRICT"):
self._run()
assert not prepare.called
def test_cmake_phase_failure_raises(self, compile_ctx: CompileCtx) -> None:
run_cmd, prepare, _ = compile_ctx
run_cmd.side_effect = [False]
with pytest.raises(EsphomeError, match="configure failed"):
self._run()
assert not prepare.called
def test_ccache_pch_env_reaches_west(self, compile_ctx: CompileCtx) -> None:
run_cmd, _, _ = compile_ctx
run_cmd.side_effect = [False]
# clear=True also drops ambient CCACHE_*/ESPHOME_PCH_* overrides
with (
patch.dict("os.environ", {}, clear=True),
pytest.raises(EsphomeError, match="configure failed"),
):
self._run()
env = run_cmd.call_args.kwargs["env"]
assert env["CCACHE_PCH_EXTSUM"] == "true"
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
@pytest.mark.parametrize("sysbuild", [False, True])
def test_settled_db_skips_cmake_phase(
self, sysbuild: bool, compile_ctx: CompileCtx
) -> None:
run_cmd, prepare, build_dir = compile_ctx
app = build_dir / "zephyr" if sysbuild else build_dir
app.mkdir(parents=True)
# A present top-level cache keeps the pristine wipe from dropping
# the DB; the app-dir cache is the sysbuild layout marker
(build_dir / "CMakeCache.txt").write_text("")
(app / "CMakeCache.txt").write_text("")
(app / "compile_commands.json").write_text("[]")
run_cmd.side_effect = [False]
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
self._run()
assert run_cmd.call_count == 1
assert "--cmake-only" not in run_cmd.call_args.args[0]
assert prepare.call_args.args[0] == app
def test_disabled_skips_header_and_cmake_phase(
self, monkeypatch: pytest.MonkeyPatch, compile_ctx: CompileCtx
) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
run_cmd, prepare, build_dir = compile_ctx
run_cmd.side_effect = [False]
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
self._run()
assert run_cmd.call_count == 1
assert not (build_dir / "esphome_pch.h").exists()
# The wrapper still runs: it discards stale sidecars and feeds strict
assert prepare.called
def test_prepare_failure_never_aborts_the_build(
self, compile_ctx, caplog: pytest.LogCaptureFixture
) -> None:
run_cmd, prepare, build_dir = compile_ctx
build_dir.mkdir(parents=True)
# Keep the pristine wipe from dropping the dir the fallback touches
(build_dir / "CMakeCache.txt").write_text("")
prepare.side_effect = RuntimeError("boom")
run_cmd.side_effect = [True, True, False]
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
self._run()
assert run_cmd.call_count == 3
assert "Precompiled header setup failed" in caplog.text
# The fallback still satisfies OBJECT_DEPENDS
assert (build_dir / "esphome_pch.h").is_file()
def test_prepare_failure_strict_raises(
self, monkeypatch: pytest.MonkeyPatch, compile_ctx: CompileCtx
) -> None:
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
run_cmd, prepare, _ = compile_ctx
prepare.side_effect = RuntimeError("boom")
run_cmd.side_effect = [True, True]
with pytest.raises(RuntimeError, match="boom"):
self._run()
+10
View File
@@ -568,6 +568,16 @@ class TestEsphomeCore:
target.config_path = Path("foo/config")
return target
def test_add_platformio_option_merges_string_into_list(self, target) -> None:
"""A user platformio_options string lands after a component's list
(FINAL priority) and must merge as one element, not assert."""
target.add_platformio_option("extra_scripts", ["post:pch.py"])
target.add_platformio_option("extra_scripts", "pre:mine.py")
assert target.platformio_options["extra_scripts"] == [
"post:pch.py",
"pre:mine.py",
]
def test_reset(self, target):
"""Call reset on target and compare to new instance"""
other = core.EsphomeCore().__dict__
+10 -2
View File
@@ -19,6 +19,7 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome.build_helpers.pch import _PCHData
from esphome.espidf.framework import (
ESPHOME_STAMP_FILE,
STAMP_SCHEMA_VERSION,
@@ -1592,10 +1593,14 @@ def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None):
"esphome.espidf.framework.get_idf_tools_path",
return_value=tmp_path / "tools",
),
# ccache_defaults_env (build_helpers.ccache) reads CORE at call time
# ccache_defaults_env and the pch emission flag read CORE at call time
patch(
"esphome.core.CORE",
SimpleNamespace(build_path=build_path),
SimpleNamespace(
build_path=build_path,
# Pre-marked: these env tests model a pch-emitting build
data={"pch": _PCHData(emitted=True)},
),
),
)
@@ -1609,6 +1614,9 @@ def test_ccache_env_default_enabled_when_available(tmp_path: Path) -> None:
assert env["CCACHE_NOHASHDIR"] == "true"
assert env["CCACHE_DEPEND"] == "1"
assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve())
# The pch cannot cache under ccache without these
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
assert env["CCACHE_PCH_EXTSUM"] == "true"
def test_ccache_env_disabled_when_binary_missing(tmp_path: Path) -> None:
+79
View File
@@ -93,6 +93,13 @@ def test_get_configured_targets_ci_installs_all(monkeypatch: pytest.MonkeyPatch)
assert toolchain._get_configured_targets() is None
@pytest.fixture(autouse=True)
def _no_ccache(monkeypatch: pytest.MonkeyPatch) -> None:
"""Deterministic run_compile: no host ccache probe, no pch work."""
monkeypatch.setenv("IDF_CCACHE_ENABLE", "0")
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
def _setup_build(setup_core: Path) -> tuple[Path, Path]:
"""Point CORE at a build dir; return (compile_commands, idedata cache) paths."""
CORE.name = "test"
@@ -660,3 +667,75 @@ def test_get_core_framework_version_from_core_data():
CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}}
assert toolchain._get_core_framework_version() == "5.5.4"
def test_run_compile_aborts_when_stale_pch_survives_discard(
setup_core: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An undiscardable stale .gch means silently wrong output: abort."""
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
_setup_build(setup_core)
with (
patch.object(toolchain, "need_reconfigure", return_value=False),
patch.object(toolchain, "run_idf_py", return_value=0),
patch.object(toolchain, "print_summary"),
patch("esphome.build_gen.espidf.prepare_pch", side_effect=RuntimeError("boom")),
patch(
"esphome.build_helpers.pch.discard_pch",
side_effect=EsphomeError("Could not discard the stale precompiled header"),
),
pytest.raises(EsphomeError, match="Could not discard"),
):
toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
def test_run_compile_strict_reraises_pch_failure(
setup_core: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""ESPHOME_PCH_STRICT must reach through the real compile flow."""
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
_setup_build(setup_core)
with (
patch.object(toolchain, "need_reconfigure", return_value=False),
patch.object(toolchain, "run_idf_py", return_value=0),
patch.object(toolchain, "print_summary"),
patch(
"esphome.build_gen.espidf.prepare_pch",
side_effect=EsphomeError("ESPHOME_PCH_STRICT: no usable compile command"),
),
pytest.raises(EsphomeError, match="no usable compile command"),
):
toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
def test_run_compile_invokes_prepare_pch_and_survives_failure(
setup_core: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The pch hook runs before the build and a failure never aborts it."""
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
_setup_build(setup_core)
# A stale .gch must be discarded on the failure path, never consumed
build = setup_core / "build" / "test" / "build"
build.mkdir(parents=True, exist_ok=True)
(build / "esphome_pch.h").write_text("")
stale_gch = build / "esphome_pch.h.gch"
stale_gch.write_bytes(b"stale")
with (
patch.object(toolchain, "need_reconfigure", return_value=False),
patch.object(toolchain, "run_idf_py", return_value=0),
patch.object(toolchain, "print_summary"),
patch(
"esphome.build_gen.espidf.prepare_pch", side_effect=RuntimeError("boom")
) as prepare,
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
prepare.assert_called_once()
assert not stale_gch.exists()
+21
View File
@@ -0,0 +1,21 @@
"""The pch prefix must keep resolving; a rename would silently
collapse the precompiled set to defines.h with strict CI still green."""
from pathlib import Path
import re
from esphome.build_helpers.pch import PCH_PREFIX_HEADER
REPO = Path(__file__).parents[2]
def test_pch_prefix_resolves() -> None:
prefix = REPO / PCH_PREFIX_HEADER
assert prefix.is_file()
body = prefix.read_text()
includes = re.findall(r'#include "([^"]+)"', body)
assert includes, "prefix wrapper folds nothing"
for name in includes:
assert (REPO / name).is_file(), f"{name} does not resolve"
# The C guard is what keeps build_src_flags safe on C/assembly edges
assert "#ifdef __cplusplus" in body
+125 -4
View File
@@ -100,12 +100,14 @@ def _fake_cxx(
body += f"echo {fail_msg or 'boom'} >&2\nexit 1\n"
else:
# Only the c++-header compile has a -o; the load probe has none
body += 'out=""; prev=""; mf=0; dep=0\nfor a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; [ "$a" = "-MF" ] && mf=1; case "$a" in -M|-MM|-MD|-MMD) dep=1;; esac; done\n'
body += 'out=""; prev=""; mf=0; dep=0; inc=0\nfor a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; [ "$a" = "-MF" ] && mf=1; [ "$a" = "-include" ] && inc=1; case "$a" in -M|-MM|-MD|-MMD) dep=1;; esac; done\n'
# Real cc1plus rejects -MF without a dependency flag
body += 'if [ "$mf" = 1 ] && [ "$dep" = 0 ]; then echo "cc1plus: error: to generate dependencies you must specify either \x27-M\x27 or \x27-MM\x27" >&2; exit 1; fi\n'
body += '[ -n "$out" ] && echo gch > "$out"\n'
if reject_pch:
body += 'case " $* " in *c++-header*) ;; *) echo "warning: esphome_pch.h.gch: had text segment at different address" >&2;; esac\n'
# -Werror=invalid-pch makes rejection a nonzero exit; the
# baseline (no -include) still passes
body += 'case " $* " in *c++-header*) ;; *) if [ "$inc" = 1 ]; then echo "error: esphome_pch.h.gch: had text segment at different address" >&2; exit 1; fi;; esac\n'
body += f'case " $* " in *c++-header*) exit 0;; *) exit {probe_exit};; esac\n'
cxx.write_text("#!/bin/sh\n" + body)
cxx.chmod(cxx.stat().st_mode | stat.S_IEXEC)
@@ -293,13 +295,17 @@ def test_pch_script_spawn_failure_is_transient(
assert "did not run" in capsys.readouterr().out
def test_pch_script_probe_nonzero_exit_falls_back(tmp_path: Path) -> None:
"""A probe failure whose stderr never mentions .gch must still count."""
def test_pch_script_probe_baseline_failure_latches_with_honest_label(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Probe AND baseline failing is a deterministic environment problem:
latch, but blame the environment rather than the pch."""
scons_env = _run_script(tmp_path, probe_exit=1)
proj = tmp_path / "dev"
assert not (proj / "esphome_pch.h.gch").exists()
assert (proj / "esphome_pch.h.gch.failed").is_file()
assert scons_env.prepended == []
assert "probe cannot run at all" in capsys.readouterr().out
def test_pch_script_unresolved_package_version_skips_pch(tmp_path: Path) -> None:
@@ -458,3 +464,118 @@ def test_pch_script_unreadable_local_header_skips_pch(
assert not (proj / "esphome_pch.h.gch.sum").exists()
assert scons_env.prepended == []
assert "skipping precompiled header" in capsys.readouterr().out
def test_pch_script_rejects_unrecognized_strict_value(tmp_path: Path) -> None:
"""A typo'd knob must fail the build, not silently disable the gate."""
with pytest.raises(RuntimeError, match="Unrecognized ESPHOME_PCH_STRICT"):
_run_script(tmp_path, env_vars={"ESPHOME_PCH_STRICT": "yolo"})
def test_pch_script_strict_tables_match_helpers(tmp_path: Path) -> None:
"""The script's mirrored spelling tables must not drift."""
from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS
proj = tmp_path / "dev"
(proj / "src").mkdir(parents=True)
# Hermetic: the constants are module-level, but the exec still runs
# _setup_pch, which must not touch the host toolchain
cxx = _fake_cxx(tmp_path)
env = _FakeSConsEnv(proj, proj / "src", str(cxx), ["-DX=1"])
namespace = {"Import": lambda *_names: None, "env": env, "projenv": env}
with patch.dict(os.environ, {}, clear=True):
exec(compile(_SCRIPT.read_text(), "pch.py", "exec"), namespace) # noqa: S102
assert set(namespace["_TRUTHY"]) == set(TRUTHY_ENV_STRINGS)
# parse_enable_env handles the empty string separately
assert set(namespace["_FALSY"]) - {""} == set(FALSY_ENV_STRINGS)
def test_pch_script_strict_reprobes_cached_gch(tmp_path: Path) -> None:
"""Rejection is per-process: strict re-proves a cached .gch loads."""
_run_script(tmp_path)
proj = tmp_path / "dev"
assert (proj / "esphome_pch.h.gch").is_file()
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
# Second run: cache fresh, but the toolchain now rejects loads
with pytest.raises(RuntimeError, match="not used"):
_run_script(tmp_path, reject_pch=True, env_vars={"ESPHOME_PCH_STRICT": "1"})
assert not (proj / "esphome_pch.h.gch").exists()
def test_pch_script_strict_raises_when_pch_not_used(tmp_path: Path) -> None:
"""ESPHOME_PCH_STRICT fails the build instead of degrading."""
with pytest.raises(RuntimeError, match="ESPHOME_PCH_STRICT"):
_run_script(tmp_path, fail=True, env_vars={"ESPHOME_PCH_STRICT": "1"})
def test_pch_script_strict_fails_without_scons(tmp_path: Path) -> None:
"""No SCons under PlatformIO is an anomaly; strict must not pass."""
proj = tmp_path / "dev"
(proj / "src").mkdir(parents=True)
def strict_import(*names: str) -> None:
if "projenv" in names:
raise RuntimeError("Import of non-existent variable 'projenv'")
import sys
env = _FakeSConsEnv(proj, proj / "src", "g++", ["-DX=1"])
with (
# None forces ImportError even where SCons is installed
patch.dict(sys.modules, {"SCons.Script": None}),
patch.dict(os.environ, {"ESPHOME_PCH_STRICT": "1"}, clear=True),
pytest.raises(RuntimeError, match="not used"),
):
exec( # noqa: S102
compile(_SCRIPT.read_text(), "pch.py", "exec"),
{"Import": strict_import, "env": env},
)
def test_pch_script_strict_reraises_internal_errors(tmp_path: Path) -> None:
"""The catch-all must not swallow programming errors in strict mode."""
with pytest.raises(TypeError):
_run_script(tmp_path, env_vars={"ESPHOME_PCH_STRICT": "1"}, platform_cls=None)
@pytest.mark.parametrize(("targets", "passes"), [(["nobuild"], True), ([], False)])
def test_pch_script_strict_projenv_skip_gated_on_nobuild(
tmp_path: Path, targets: list[str], passes: bool
) -> None:
"""-t nobuild compiles nothing, so the skip passes strict; a missing
projenv on a real compile must not."""
import sys
import types
proj = tmp_path / "dev"
(proj / "src").mkdir(parents=True)
def strict_import(*names: str) -> None:
if "projenv" in names:
raise RuntimeError("Import of non-existent variable 'projenv'")
scons = types.ModuleType("SCons")
scons_script = types.ModuleType("SCons.Script")
scons_script.COMMAND_LINE_TARGETS = targets
env = _FakeSConsEnv(proj, proj / "src", "g++", ["-DX=1"])
with (
patch.dict(sys.modules, {"SCons": scons, "SCons.Script": scons_script}),
patch.dict(os.environ, {"ESPHOME_PCH_STRICT": "1"}, clear=True),
):
run = lambda: exec( # noqa: S102, E731
compile(_SCRIPT.read_text(), "pch.py", "exec"),
{"Import": strict_import, "env": env},
)
if passes:
run()
else:
with pytest.raises(RuntimeError, match="not used"):
run()
assert not (proj / "esphome_pch.h").exists()
def test_pch_script_strict_passes_on_success(tmp_path: Path) -> None:
scons_env = _run_script(tmp_path, env_vars={"ESPHOME_PCH_STRICT": "1"})
# Strict escalates the consumer edges too
assert "-Werror=invalid-pch" in scons_env.prepended
assert "-Wno-error=invalid-pch" not in scons_env.prepended