From 3279410a47aa82e3886b6dbb64dd8955e1dc3817 Mon Sep 17 00:00:00 2001 From: philipph-askui Date: Fri, 7 Aug 2026 10:23:59 -0700 Subject: [PATCH 1/2] fix: reject zero-size screen captures before they reach the reporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the AskUI controller returns a 0×0 bitmap (e.g. because the display is minimised or unavailable), PIL cannot encode the resulting image to PNG. This caused the SimpleHtmlReporter to crash and be permanently disabled by ReporterErrorHandler, losing the entire HTML report for the run. Two-layer fix: - askui_controller.py: `_check_bitmap_dimensions()` raises `AskUiControllerError` immediately after a zero-size bitmap is received, before any PIL image is constructed or passed downstream. - reporting.py: `normalize_to_pil_images()` now filters out any zero-size PIL images as defence-in-depth, so no reporter can crash on an empty image regardless of its origin. Co-Authored-By: Claude Sonnet 4.6 --- src/askui/reporting.py | 16 +++++--- src/askui/tools/askui/askui_controller.py | 27 +++++++++++- tests/unit/test_reporting.py | 41 ++++++++++++++++++- .../askui/test_askui_controller_client.py | 19 +++++++++ 4 files changed, 96 insertions(+), 7 deletions(-) diff --git a/src/askui/reporting.py b/src/askui/reporting.py index 4090ea9b..f1c49648 100644 --- a/src/askui/reporting.py +++ b/src/askui/reporting.py @@ -34,14 +34,20 @@ def normalize_to_pil_images( image: Image.Image | list[Image.Image] | AnnotatedImage | None, ) -> list[Image.Image]: - """Normalize various image input types to a list of PIL images.""" + """Normalize various image input types to a list of PIL images. + + Zero-size images (width or height of 0) are filtered out because PIL + cannot encode them to PNG, which would crash any reporter that tries. + """ if image is None: return [] if isinstance(image, AnnotatedImage): - return image.get_images() - if isinstance(image, list): - return image - return [image] + images: list[Image.Image] = image.get_images() + elif isinstance(image, list): + images = image + else: + images = [image] + return [img for img in images if img.width > 0 and img.height > 0] def _format_duration(seconds: float) -> str: diff --git a/src/askui/tools/askui/askui_controller.py b/src/askui/tools/askui/askui_controller.py index fc892858..6bb879e2 100644 --- a/src/askui/tools/askui/askui_controller.py +++ b/src/askui/tools/askui/askui_controller.py @@ -378,6 +378,28 @@ def __exit__( """ self.disconnect() + @staticmethod + def _check_bitmap_dimensions(width: int, height: int) -> None: + """Raise `AskUiControllerError` when the captured bitmap has zero dimensions. + + A zero-size bitmap is returned when the display is unavailable, minimized, + or otherwise unable to produce a frame. PIL cannot encode such an image, + so we reject it here before it reaches the reporter or the model. + + Args: + width (int): Bitmap width returned by `CaptureScreen`. + height (int): Bitmap height returned by `CaptureScreen`. + + Raises: + AskUiControllerError: If either `width` or `height` is zero. + """ + if width == 0 or height == 0: + error_msg = ( + f"Screen capture returned an empty bitmap ({width}×{height}). " + "The display may be unavailable, minimized, or zero-sized." + ) + raise AskUiControllerError(error_msg) + @telemetry.record_call() @override def screenshot(self, report: bool = True, unscaled: bool = False) -> Image.Image: @@ -403,9 +425,12 @@ def screenshot(self, report: bool = True, unscaled: bool = False) -> Image.Image ), ) ) + width = screenResponse.bitmap.width + height = screenResponse.bitmap.height + self._check_bitmap_dimensions(width, height) r, g, b, _ = Image.frombytes( "RGBA", - (screenResponse.bitmap.width, screenResponse.bitmap.height), + (width, height), screenResponse.bitmap.data, ).split() image = Image.merge("RGB", (b, g, r)) diff --git a/tests/unit/test_reporting.py b/tests/unit/test_reporting.py index 1e87779c..da5d7837 100644 --- a/tests/unit/test_reporting.py +++ b/tests/unit/test_reporting.py @@ -7,7 +7,9 @@ from typing import Any -from askui.reporting import truncate_base64_media +from PIL import Image + +from askui.reporting import normalize_to_pil_images, truncate_base64_media def _base64_source(media_type: str) -> dict[str, Any]: @@ -58,3 +60,40 @@ def test_leaves_plain_content_untouched(self) -> None: "type": "text", "text": "hello", } + + +class TestNormalizeToPilImages: + def test_none_returns_empty_list(self) -> None: + assert normalize_to_pil_images(None) == [] + + def test_single_valid_image_is_wrapped_in_list(self) -> None: + img = Image.new("RGB", (10, 10)) + result = normalize_to_pil_images(img) + assert result == [img] + + def test_list_of_valid_images_is_returned_as_is(self) -> None: + images = [Image.new("RGB", (10, 10)), Image.new("RGB", (20, 20))] + result = normalize_to_pil_images(images) + assert result == images + + def test_zero_width_image_is_filtered_out(self) -> None: + empty = Image.new("RGB", (0, 5)) + valid = Image.new("RGB", (10, 10)) + result = normalize_to_pil_images([empty, valid]) + assert result == [valid] + + def test_zero_height_image_is_filtered_out(self) -> None: + empty = Image.new("RGB", (5, 0)) + valid = Image.new("RGB", (10, 10)) + result = normalize_to_pil_images([empty, valid]) + assert result == [valid] + + def test_zero_by_zero_image_is_filtered_out(self) -> None: + result = normalize_to_pil_images(Image.new("RGB", (0, 0))) + assert result == [] + + def test_list_of_only_empty_images_returns_empty_list(self) -> None: + result = normalize_to_pil_images( + [Image.new("RGB", (0, 0)), Image.new("RGB", (0, 5))] + ) + assert result == [] diff --git a/tests/unit/tools/askui/test_askui_controller_client.py b/tests/unit/tools/askui/test_askui_controller_client.py index 4c007f5a..70a5a27b 100644 --- a/tests/unit/tools/askui/test_askui_controller_client.py +++ b/tests/unit/tools/askui/test_askui_controller_client.py @@ -214,3 +214,22 @@ def test_underlying_manager_is_an_agent_os_target_computer_manager(self) -> None agent_os_target_computers=[_make_local(computer_id="l")] ) assert isinstance(client.agent_os_target_computer_manager, ComputerTargetPool) + + +class TestScreenshotValidation: + """_check_bitmap_dimensions() raises AskUiControllerError for zero-size bitmaps.""" + + def test_zero_width_raises(self) -> None: + with pytest.raises(AskUiControllerError, match="empty bitmap"): + MultiComputerTargetAgentOS._check_bitmap_dimensions(0, 100) + + def test_zero_height_raises(self) -> None: + with pytest.raises(AskUiControllerError, match="empty bitmap"): + MultiComputerTargetAgentOS._check_bitmap_dimensions(100, 0) + + def test_zero_by_zero_raises(self) -> None: + with pytest.raises(AskUiControllerError, match="empty bitmap"): + MultiComputerTargetAgentOS._check_bitmap_dimensions(0, 0) + + def test_valid_dimensions_do_not_raise(self) -> None: + MultiComputerTargetAgentOS._check_bitmap_dimensions(1920, 1080) From f5030b9651c926f429f1ff6c14aa7178c02a5623 Mon Sep 17 00:00:00 2001 From: philipph-askui Date: Fri, 7 Aug 2026 11:20:12 -0700 Subject: [PATCH 2/2] fix: warn when a zero-size image is dropped by normalize_to_pil_images Co-Authored-By: Claude Sonnet 4.6 --- src/askui/reporting.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/askui/reporting.py b/src/askui/reporting.py index f1c49648..99c4d43f 100644 --- a/src/askui/reporting.py +++ b/src/askui/reporting.py @@ -47,7 +47,17 @@ def normalize_to_pil_images( images = image else: images = [image] - return [img for img in images if img.width > 0 and img.height > 0] + valid = [] + for img in images: + if img.width == 0 or img.height == 0: + logger.warning( + "Skipping zero-size image (%dx%d) — cannot encode an empty image.", + img.width, + img.height, + ) + else: + valid.append(img) + return valid def _format_duration(seconds: float) -> str: