From 63a3dff118975dd9939f7253a0c6cd414cffe07a Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 19 Nov 2025 12:21:27 +0100 Subject: [PATCH] Handle pull events with complete progress details only (#6320) * Handle pull events with complete progress details only Under certain circumstances, Docker seems to send pull events with incomplete progress details (i.e., missing 'current' or 'total' fields). In practise, we've observed an empty dictionary for progress details as well as missing 'total' field (while 'current' was present). All events were using Docker 28.3.3 using the old, default Docker graph backend. * Fix docstring/comment --- supervisor/docker/interface.py | 2 + tests/docker/test_interface.py | 96 +++++++++++++++++++++++++++------- 2 files changed, 78 insertions(+), 20 deletions(-) diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index f9f11e542..a36c7182f 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -310,6 +310,8 @@ class DockerInterface(JobGroup, ABC): if ( stage in {PullImageLayerStage.DOWNLOADING, PullImageLayerStage.EXTRACTING} and reference.progress_detail + and reference.progress_detail.current is not None + and reference.progress_detail.total is not None ): job.update( progress=progress, diff --git a/tests/docker/test_interface.py b/tests/docker/test_interface.py index 87eb4a41e..ac3c1770f 100644 --- a/tests/docker/test_interface.py +++ b/tests/docker/test_interface.py @@ -445,28 +445,23 @@ async def test_install_progress_rounding_does_not_cause_misses( ] coresys.docker.images.pull.return_value = AsyncIterator(logs) - with ( - patch.object( - type(coresys.supervisor), "arch", PropertyMock(return_value="i386") - ), - ): - # Schedule job so we can listen for the end. Then we can assert against the WS mock - event = asyncio.Event() - job, install_task = coresys.jobs.schedule_job( - test_docker_interface.install, - JobSchedulerOptions(), - AwesomeVersion("1.2.3"), - "test", - ) + # Schedule job so we can listen for the end. Then we can assert against the WS mock + event = asyncio.Event() + job, install_task = coresys.jobs.schedule_job( + test_docker_interface.install, + JobSchedulerOptions(), + AwesomeVersion("1.2.3"), + "test", + ) - async def listen_for_job_end(reference: SupervisorJob): - if reference.uuid != job.uuid: - return - event.set() + async def listen_for_job_end(reference: SupervisorJob): + if reference.uuid != job.uuid: + return + event.set() - coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, listen_for_job_end) - await install_task - await event.wait() + coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, listen_for_job_end) + await install_task + await event.wait() capture_exception.assert_not_called() @@ -664,3 +659,64 @@ async def test_install_progress_handles_layers_skipping_download( assert job.done is True assert job.progress == 100 capture_exception.assert_not_called() + + +async def test_missing_total_handled_gracefully( + coresys: CoreSys, + test_docker_interface: DockerInterface, + ha_ws_client: AsyncMock, + capture_exception: Mock, +): + """Test missing 'total' fields in progress details handled gracefully.""" + coresys.core.set_state(CoreState.RUNNING) + + # Progress details with missing 'total' fields observed in real-world pulls + logs = [ + { + "status": "Pulling from home-assistant/odroid-n2-homeassistant", + "id": "2025.7.1", + }, + {"status": "Pulling fs layer", "progressDetail": {}, "id": "1e214cd6d7d0"}, + { + "status": "Downloading", + "progressDetail": {"current": 436480882}, + "progress": "[===================================================] 436.5MB/436.5MB", + "id": "1e214cd6d7d0", + }, + {"status": "Verifying Checksum", "progressDetail": {}, "id": "1e214cd6d7d0"}, + {"status": "Download complete", "progressDetail": {}, "id": "1e214cd6d7d0"}, + { + "status": "Extracting", + "progressDetail": {"current": 436480882}, + "progress": "[===================================================] 436.5MB/436.5MB", + "id": "1e214cd6d7d0", + }, + {"status": "Pull complete", "progressDetail": {}, "id": "1e214cd6d7d0"}, + { + "status": "Digest: sha256:7d97da645f232f82a768d0a537e452536719d56d484d419836e53dbe3e4ec736" + }, + { + "status": "Status: Downloaded newer image for ghcr.io/home-assistant/odroid-n2-homeassistant:2025.7.1" + }, + ] + coresys.docker.images.pull.return_value = AsyncIterator(logs) + + # Schedule job so we can listen for the end. Then we can assert against the WS mock + event = asyncio.Event() + job, install_task = coresys.jobs.schedule_job( + test_docker_interface.install, + JobSchedulerOptions(), + AwesomeVersion("1.2.3"), + "test", + ) + + async def listen_for_job_end(reference: SupervisorJob): + if reference.uuid != job.uuid: + return + event.set() + + coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, listen_for_job_end) + await install_task + await event.wait() + + capture_exception.assert_not_called()