diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 1a128bb12b..07c6e085fc 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -797,11 +797,10 @@ def run_batch_downloads( try: fetch(checked) - except _BatchDownloadCancelled as err: - # Reported like a failure: an abandoned job must never read as - # a completed download if a caller sees the list after Ctrl-C - failure = (name, err) - except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught + except (_BatchDownloadCancelled, Exception) as err: # noqa: BLE001 # pylint: disable=broad-exception-caught + # A cancelled job reports like a failure: an abandoned download + # must never read as completed if a caller sees the list after + # Ctrl-C failure = (name, err) else: return None @@ -848,12 +847,12 @@ class _BatchDownloadProgress: self._lock = threading.Lock() def tracker(self) -> Callable[[int], None]: + if self._bar is None: + return lambda _: None last = 0 def update(done: int) -> None: nonlocal last - if self._bar is None: - return with self._lock: self._sum += done - last last = done @@ -883,13 +882,7 @@ class _BatchDownloadProgress: class _EndRow(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: with lock: - if ( - the_bar.last_progress is not None - and the_bar.last_progress != 100 - ): - the_bar.done() - # Force the next tick to redraw the frame - the_bar.last_progress = None + the_bar.interrupt() return True end_row = _EndRow() @@ -903,6 +896,11 @@ class _BatchDownloadProgress: handler.removeFilter(end_row) +def _part_path(dest: Path) -> Path: + """The in-progress sidecar ``download_with_resume`` streams into.""" + return dest.with_name(dest.name + ".part") + + def _cancellable_sleep( delay: float, progress: Callable[[int], None] | None, done: int ) -> None: @@ -963,7 +961,7 @@ def download_with_resume( ensure_happy_eyeballs() dest = Path(dest) - part = dest.with_name(dest.name + ".part") + part = _part_path(dest) meta = part.with_name(part.name + ".meta") dest.parent.mkdir(parents=True, exist_ok=True) last_error: Exception | None = None @@ -1086,7 +1084,7 @@ def download_with_resume( ) from last_error -def _failure_reason(e: Exception) -> str: +def _failure_reason(e: BaseException) -> str: """Format a download exception for the aggregated error message. ``requests`` appends " for url: " to HTTP errors; the URL is already @@ -1357,7 +1355,14 @@ def download_from_mirrors( sweep + 1, _MIRROR_SWEEP_ATTEMPTS, ) - _cancellable_sleep(delay, progress, 0) + # Tick with the bytes already on disk so a combined bar holds + # steady during the backoff instead of rewinding to zero + if f is not None: + done = f.tell() + else: + part = _part_path(path_target) + done = part.stat().st_size if part.is_file() else 0 + _cancellable_sleep(delay, progress, done) # 4. Report every attempted URL if all mirrors failed. failures spans # all sweeps (deduplicated by URL and reason), so neither an early diff --git a/esphome/helpers.py b/esphome/helpers.py index 64335ffd06..f7c1695ef1 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -741,6 +741,16 @@ class ProgressBar: sys.stderr.write("\n") sys.stderr.flush() + def interrupt(self) -> None: + """End a mid-row frame so the next write starts on its own row. + + The next ``update()`` redraws the bar; a finished bar stays done. + """ + if self.last_progress == 100: + return + self.done() + self.last_progress = None + def docs_url(path: str) -> str: """Return the URL to the documentation for a given path.""" diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 537e7d1c59..7fc5c6e357 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -77,6 +77,10 @@ SRC_FILE_EXTENSIONS = list(SOURCE_KIND_FOR_SUFFIX) DOMAIN = "pio_components" +# Marks a cache dir whose archive finished extracting; a missing marker +# means a torn extraction that must be redone +_EXTRACTED_MARKER = ".esphome_extracted" + ESPHOME_DATA_KEY = "ESPHOME" ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" @@ -118,7 +122,7 @@ class URLSource(Source): def is_cached(self, dir_suffix: str, salt: str = "", namespace: str = "") -> bool: """Whether a completed extraction already exists for this source.""" return ( - self._cache_dir(dir_suffix, salt, namespace) / ".esphome_extracted" + self._cache_dir(dir_suffix, salt, namespace) / _EXTRACTED_MARKER ).is_file() def download( @@ -135,7 +139,7 @@ class URLSource(Source): # extraction is correctly detected and re-run on the next invocation, # and lets us extract directly into ``path`` — avoiding a # post-extraction rename that races with antivirus on Windows. - extracted_marker = path / ".esphome_extracted" + extracted_marker = path / _EXTRACTED_MARKER if not extracted_marker.is_file() or force: rmdir(path, msg=f"Clean up library directory {path}") diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index f776b68ac3..f374becb30 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -1703,6 +1703,27 @@ class TestDownloadFromMirrors: assert mock_get.call_count == 2 mock_sleep.assert_called_once_with(2) + def test_backoff_tick_reports_partial_bytes(self, tmp_path: Path) -> None: + """The backoff tick carries the bytes already in the part file, so a + combined bar holds steady instead of rewinding to zero.""" + dest = tmp_path / "out.bin" + (tmp_path / "out.bin.part").write_bytes(b"12345") + ticks: list[int] = [] + with ( + patch( + "requests.get", + side_effect=[ + req.ConnectionError("down"), + _mock_response(b"data"), + ], + ), + patch("esphome.framework_helpers._cancellable_sleep") as mock_sleep, + ): + download_from_mirrors( + ["https://mirror1.com/f"], {}, dest, progress=ticks.append + ) + assert mock_sleep.call_args == call(2, ticks.append, 5) + def test_permanent_failure_does_not_retry_sweep(self, tmp_path: Path) -> None: """An HTTP 404 will not heal on its own; fail after a single pass.""" with (