diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index b1c34fed36..7e889a2d1d 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -779,15 +779,25 @@ def run_batch_downloads( collected (list.append is atomic under the GIL) and returned after the bar is done, so the caller's warnings never land on the bar's row; a failed job credits its tracker 0 so the bar can still complete. Ctrl-C - drops queued jobs instead of downloading them all before the process - can exit; in-flight ones still finish. ``jobs`` must be non-empty. + drops queued jobs and aborts in-flight ones at their next progress + tick; resumable ``.part`` files keep the bytes already fetched. + ``jobs`` must be non-empty. """ failures: list[tuple[str, Exception]] = [] + cancelled = threading.Event() def _run(name: str, fetch: Callable[[Callable[[int], None]], None]) -> None: tracker = progress.tracker() + + def checked(done: int) -> None: + if cancelled.is_set(): + raise _BatchDownloadCancelled + tracker(done) + try: - fetch(tracker) + fetch(checked) + except _BatchDownloadCancelled: + tracker(0) except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught failures.append((name, err)) tracker(0) @@ -796,12 +806,21 @@ def run_batch_downloads( try: for future in [ex.submit(_run, name, fetch) for name, fetch in jobs]: future.result() + except BaseException: + # Without this the non-daemon workers download to completion before + # the interpreter can exit, making Ctrl-C ineffective for minutes + cancelled.set() + raise finally: ex.shutdown(wait=True, cancel_futures=True) progress.done() return failures +class _BatchDownloadCancelled(Exception): + """Raised inside a download job to abandon it after Ctrl-C.""" + + class BatchDownloadProgress: """One progress bar across several concurrent ``download_with_resume`` calls. diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index e7ce437570..f3622ffc52 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -59,7 +59,8 @@ DEFAULT_BUILD_FLAGS = [] # "asm" merges SCons's AS and ASPP sets: all compile as assembler-with-cpp. # The kind values drive the ESP8266 native ninja rules (later in this # chain); existing backends consume only the keys. Note .C/.C++ join the -# suffix set here, matching PlatformIO's CXXSUFFIXES. +# suffix set here per CXXSUFFIXES; SCons demotes .C to C on +# case-insensitive filesystems, we always treat it as C++. SOURCE_KIND_FOR_SUFFIX: dict[str, str] = { ".c": "c", ".cpp": "cxx", diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 6fbdd2fb19..cecf49325f 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -984,9 +984,13 @@ def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None: kwargs = calls[("https://example.com/cmake.tar.gz", dist / "cmake-3.30.2.tar.gz")] assert kwargs["sha256"] == "ab" * 32 assert kwargs["size"] == 123 - # every archive reports into the one combined progress bar + # every archive reports into the one combined progress bar via the + # cancellation-checked wrapper; verify it delegates to the tracker progress_cls.assert_called_once_with("Downloading ESP-IDF tools", 123 + 45) - assert all(kw["progress"] is tracker for kw in calls.values()) + before = tracker.call_count + for kw in calls.values(): + kw["progress"](7) + assert tracker.call_count == before + len(calls) def test_prefetch_downloads_archives_concurrently(tmp_path: Path) -> None: diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 6be152144a..f88bcc2170 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -1158,6 +1158,40 @@ class TestDownloadWithResume: assert seen == [8] +def test_run_batch_downloads_ctrl_c_aborts_in_flight_jobs() -> None: + """Ctrl-C cancels in-flight downloads at their next tick instead of + letting non-daemon workers download to completion.""" + import threading + import time + + from esphome.framework_helpers import BatchDownloadProgress, run_batch_downloads + + started = threading.Event() + ticks: list[int] = [] + + def interrupter(tracker) -> None: + started.wait(5) + raise KeyboardInterrupt + + def slow_download(tracker) -> None: + started.set() + for i in range(500): + tracker(i) + ticks.append(i) + time.sleep(0.01) + + t0 = time.monotonic() + with pytest.raises(KeyboardInterrupt): + run_batch_downloads( + BatchDownloadProgress("Downloading", 0), + [("boom", interrupter), ("slow", slow_download)], + max_workers=2, + ) + # Uncancelled, slow_download alone takes ~5s + assert time.monotonic() - t0 < 3 + assert len(ticks) < 500 + + class TestBatchDownloadProgress: def test_sums_trackers_into_one_bar(self) -> None: with patch("esphome.framework_helpers.ProgressBar") as bar_cls: diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index e1defb0710..0abba6d71c 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -276,6 +276,8 @@ def test_wave_requirement_growth_defers_the_superseded_download(tmp_path, monkey (self.path / "library.json").write_text(json.dumps(manifests[self.name])) monkeypatch.setattr(ConvertedLibrary, "download", fake_download) + # Hermetic: unknown sizes take the sequential path instead of real HEADs + monkeypatch.setattr(lib, "_content_lengths", lambda urls: [None] * len(urls)) _patch_registry_resolve(monkeypatch) top = convert_libraries( [Library("esphome/A", "1.0.0", None), Library("esphome/B", None, None)], @@ -710,9 +712,7 @@ def test_content_lengths_head_requests(monkeypatch: pytest.MonkeyPatch) -> None: return SimpleNamespace(ok=True, headers={"content-length": "123, 123"}) return SimpleNamespace(ok=True, headers={"content-length": "123"}) - monkeypatch.setattr( - lib.requests if hasattr(lib, "requests") else requests, "head", fake_head - ) + monkeypatch.setattr(requests, "head", fake_head) # None marks an unknown size (probe failure or non-2xx), distinct # from a genuine zero assert lib._content_lengths(