diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index 1f197fcfd..6f34190c0 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -363,8 +363,15 @@ class DockerInterface(JobGroup, ABC): # Check if any layers are still pending (no extra yet) # If so, we're still in downloading phase even if all layers_with_extra are done layers_pending = len(layer_jobs) - len(layers_with_extra) - if layers_pending > 0 and stage == PullImageLayerStage.PULL_COMPLETE: - stage = PullImageLayerStage.DOWNLOADING + if layers_pending > 0: + # Scale progress to account for unreported layers + # This prevents tiny layers that complete first from showing inflated progress + # e.g., if 2/25 layers reported at 70%, actual progress is ~70 * 2/25 = 5.6% + layers_fraction = len(layers_with_extra) / len(layer_jobs) + progress = progress * layers_fraction + + if stage == PullImageLayerStage.PULL_COMPLETE: + stage = PullImageLayerStage.DOWNLOADING # Also check if all placeholders are done but we're waiting for real layers if placeholder_layers and stage == PullImageLayerStage.PULL_COMPLETE: diff --git a/tests/docker/test_interface.py b/tests/docker/test_interface.py index d00905f96..3e3059880 100644 --- a/tests/docker/test_interface.py +++ b/tests/docker/test_interface.py @@ -845,28 +845,37 @@ async def test_install_progress_containerd_snapshot( }, } - assert [c.args[0] for c in ha_ws_client.async_send_command.call_args_list] == [ - # During downloading we get continuous progress updates from download status - job_event(0), - job_event(3.4), - job_event(8.5), - job_event(10.2), - job_event(15.3), - job_event(18.8), - job_event(29.0), - job_event(35.8), - job_event(42.6), - job_event(49.5), - job_event(56.0), - job_event(62.8), - # Downloading phase is considered 70% of total. After we only get one update - # per image downloaded when extraction is finished. It uses the total size - # received during downloading to determine percent complete then. - job_event(70.0), - job_event(84.8), - job_event(100), - job_event(100, True), + # Get progress values from the events + job_events = [ + c.args[0] + for c in ha_ws_client.async_send_command.call_args_list + if c.args[0].get("data", {}).get("event") == WSEvent.JOB + and c.args[0].get("data", {}).get("data", {}).get("name") + == "mock_docker_interface_install" ] + progress_values = [e["data"]["data"]["progress"] for e in job_events] + + # Should have multiple progress updates (not just 0 and 100) + assert len(progress_values) >= 10, ( + f"Expected >=10 progress updates, got {len(progress_values)}" + ) + + # Progress should be monotonically increasing + for i in range(1, len(progress_values)): + assert progress_values[i] >= progress_values[i - 1], ( + f"Progress decreased at index {i}: {progress_values[i - 1]} -> {progress_values[i]}" + ) + + # Should start at 0 and end at 100 + assert progress_values[0] == 0 + assert progress_values[-1] == 100 + + # Should have progress values in the downloading phase (< 70%) + # Note: with layer scaling, early progress may be lower than before + downloading_progress = [p for p in progress_values if 0 < p < 70] + assert len(downloading_progress) > 0, ( + "Expected progress updates during downloading phase" + ) async def test_install_progress_containerd_snapshotter_real_world(