From 2c5193705806f59be146f5f140542abda7dd3b99 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Fri, 8 May 2026 12:16:35 -0700 Subject: [PATCH 1/2] feat(insights): Add timeout to image optimization analysis Large apps with thousands of images can cause the image optimization insight to run for several minutes. Add a 300-second deadline that cancels pending futures and returns partial results with a timed_out flag so callers can distinguish between "no optimizable images" and "analysis was cut short." Co-Authored-By: Claude --- .../size/insights/apple/image_optimization.py | 20 ++++++++++++++++++- src/launchpad/size/models/insights.py | 1 + 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/launchpad/size/insights/apple/image_optimization.py b/src/launchpad/size/insights/apple/image_optimization.py index cb1b2a7d..2df7aab7 100644 --- a/src/launchpad/size/insights/apple/image_optimization.py +++ b/src/launchpad/size/insights/apple/image_optimization.py @@ -2,6 +2,7 @@ import io import logging +import time from abc import ABC, abstractmethod from concurrent.futures import ThreadPoolExecutor, as_completed @@ -46,6 +47,7 @@ class BaseImageOptimizationInsight(Insight[ImageOptimizationInsightResult], ABC) TARGET_JPEG_QUALITY = 85 TARGET_HEIC_QUALITY = 85 _MAX_WORKERS = 4 + _TIMEOUT_SECONDS = 300 @abstractmethod def _find_images(self, input: InsightsInput) -> List[FileInfo]: @@ -73,17 +75,32 @@ def generate(self, input: InsightsInput) -> ImageOptimizationInsightResult | Non return None results: List[OptimizableImageFile] = [] + timed_out = False + completed = 0 + deadline = time.monotonic() + self._TIMEOUT_SECONDS with ThreadPoolExecutor(max_workers=min(self._MAX_WORKERS, len(files))) as executor: future_to_file = {executor.submit(self._analyze_image_optimization, f): f for f in files} for future in as_completed(future_to_file): + completed += 1 try: result = future.result() if result and result.potential_savings >= self.MIN_SAVINGS_THRESHOLD: results.append(result) except Exception: # pragma: no cover logger.exception("Failed to analyze image in thread pool") + if time.monotonic() >= deadline: + timed_out = True + logger.warning( + "size.insight.image_optimization.timeout | completed=%d/%d timeout=%ds", + completed, + len(files), + self._TIMEOUT_SECONDS, + ) + for fut in future_to_file: + fut.cancel() + break - if not results: + if not results and not timed_out: return None results.sort(key=lambda x: x.potential_savings, reverse=True) @@ -92,6 +109,7 @@ def generate(self, input: InsightsInput) -> ImageOptimizationInsightResult | Non return ImageOptimizationInsightResult( optimizable_files=results, total_savings=total_savings, + timed_out=timed_out, ) def _analyze_image_optimization( diff --git a/src/launchpad/size/models/insights.py b/src/launchpad/size/models/insights.py index 78967c62..3e32567a 100644 --- a/src/launchpad/size/models/insights.py +++ b/src/launchpad/size/models/insights.py @@ -213,6 +213,7 @@ class ImageOptimizationInsightResult(BaseInsightResult): optimizable_files: List[OptimizableImageFile] = Field( ..., description="Files that can be optimized with potential savings" ) + timed_out: bool = Field(False, description="Whether analysis was cut short due to timeout") def get_file_paths(self) -> List[str]: return [f.file_path for f in self.optimizable_files] From 0259e3aee7e522187cb2d59c948d9c3de4a52d71 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Fri, 8 May 2026 14:29:15 -0700 Subject: [PATCH 2/2] fix(insights): Prevent false timed_out flag when all images complete Guard the deadline check with `completed < len(files)` so that a complete analysis run is not misreported as partial when elapsed time happens to exceed the timeout threshold. Co-Authored-By: Claude Opus 4.6 --- src/launchpad/size/insights/apple/image_optimization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/launchpad/size/insights/apple/image_optimization.py b/src/launchpad/size/insights/apple/image_optimization.py index 2df7aab7..3a3b4de7 100644 --- a/src/launchpad/size/insights/apple/image_optimization.py +++ b/src/launchpad/size/insights/apple/image_optimization.py @@ -88,7 +88,7 @@ def generate(self, input: InsightsInput) -> ImageOptimizationInsightResult | Non results.append(result) except Exception: # pragma: no cover logger.exception("Failed to analyze image in thread pool") - if time.monotonic() >= deadline: + if completed < len(files) and time.monotonic() >= deadline: timed_out = True logger.warning( "size.insight.image_optimization.timeout | completed=%d/%d timeout=%ds",