2222 HAWebSocket ,
2323 OAuthCredentials ,
2424 WSCommandError ,
25+ _SupervisorReadinessTimeout ,
26+ _apply_supervisor_image_update ,
2527 _configure_supervisor_image_variant ,
2628 _reconnect_during_supervisor_update ,
2729 _wait_core_version ,
@@ -365,6 +367,37 @@ def test_reconnect_refreshes_the_onboarding_access_token() -> None:
365367 )
366368
367369
370+ def test_deadline_connect_bounds_the_authentication_send () -> None :
371+ """A reconnect deadline also bounds the authentication frame send."""
372+ ws = HAWebSocket (
373+ "http://127.0.0.1:18123" ,
374+ OAuthCredentials (access_token = "access" , refresh_token = "refresh" ),
375+ )
376+ socket = Mock ()
377+ socket .recv .side_effect = [
378+ json .dumps ({"type" : "auth_required" }),
379+ json .dumps ({"type" : "auth_ok" , "ha_version" : "2026.8.0" }),
380+ ]
381+ auth_message = json .dumps ({"type" : "auth" , "access_token" : "access" })
382+
383+ with (
384+ patch (
385+ "tests.haos_image_build.build_image.time.monotonic" ,
386+ return_value = 1.0 ,
387+ ),
388+ patch ("websockets.sync.client.connect" , return_value = socket ),
389+ patch .object (ws , "_send_with_deadline" ) as send ,
390+ ):
391+ ws ._connect (deadline = 10.0 )
392+
393+ send .assert_called_once_with (
394+ auth_message ,
395+ deadline = 10.0 ,
396+ operation = "WebSocket authentication send" ,
397+ )
398+ socket .send .assert_not_called ()
399+
400+
368401def test_reconnect_rejects_a_refresh_response_without_an_access_token () -> None :
369402 """A malformed refresh response fails before WebSocket authentication."""
370403 base_url = "http://127.0.0.1:18123"
@@ -600,9 +633,9 @@ def test_channel_metadata_timeout_includes_the_last_transient_error() -> None:
600633 with (
601634 patch (
602635 "tests.haos_image_build.build_image.time.monotonic" ,
603- side_effect = [0.0 , 2 .0 ],
636+ side_effect = [0.0 , 0.75 , 1 .0 ],
604637 ),
605- patch ("tests.haos_image_build.build_image.time.sleep" ),
638+ patch ("tests.haos_image_build.build_image.time.sleep" ) as sleep ,
606639 pytest .raises (TimeoutError , match = r"last error.*WSCommandError" ),
607640 ):
608641 _wait_supervisor_channel_metadata (
@@ -612,6 +645,12 @@ def test_channel_metadata_timeout_includes_the_last_transient_error() -> None:
612645 deadline = 1.0 ,
613646 )
614647
648+ ws .supervisor_api .assert_called_once_with (
649+ "/supervisor/info" , method = "get" , timeout = 1.0
650+ )
651+ ws .reconnect .assert_called_once_with (deadline = 1.0 )
652+ sleep .assert_called_once_with (0.25 )
653+
615654
616655def test_channel_metadata_timeout_prefers_the_last_reconnect_error () -> None :
617656 """A failed reconnect is the final channel-reload timeout diagnostic."""
@@ -622,9 +661,9 @@ def test_channel_metadata_timeout_prefers_the_last_reconnect_error() -> None:
622661 with (
623662 patch (
624663 "tests.haos_image_build.build_image.time.monotonic" ,
625- side_effect = [0.0 , 2 .0 ],
664+ side_effect = [0.0 , 1.0 , 1 .0 ],
626665 ),
627- patch ("tests.haos_image_build.build_image.time.sleep" ),
666+ patch ("tests.haos_image_build.build_image.time.sleep" ) as sleep ,
628667 pytest .raises (TimeoutError , match = "refresh failed" ),
629668 ):
630669 _wait_supervisor_channel_metadata (
@@ -635,6 +674,7 @@ def test_channel_metadata_timeout_prefers_the_last_reconnect_error() -> None:
635674 )
636675
637676 ws .reconnect .assert_called_once_with (deadline = 1.0 )
677+ sleep .assert_not_called ()
638678
639679
640680def test_supervisor_update_reconnect_propagates_deadline () -> None :
@@ -653,6 +693,63 @@ def test_supervisor_update_reconnect_propagates_deadline() -> None:
653693 ws .reconnect .assert_called_once_with (deadline = 10.0 )
654694
655695
696+ def test_supervisor_update_retry_caps_sleep_to_remaining_budget () -> None :
697+ """A near-expiry Supervisor retry cannot sleep past its deadline."""
698+ ws = Mock ()
699+ with (
700+ patch (
701+ "tests.haos_image_build.build_image.time.monotonic" ,
702+ side_effect = [0.0 , 0.0 , 0.75 , 1.0 ],
703+ ),
704+ patch (
705+ "tests.haos_image_build.build_image._wait_supervisor_ready" ,
706+ side_effect = [
707+ ConnectionError ("restart" ),
708+ _SupervisorReadinessTimeout ("done" ),
709+ ],
710+ ),
711+ patch ("tests.haos_image_build.build_image.time.sleep" ) as sleep ,
712+ pytest .raises (_SupervisorReadinessTimeout , match = "done" ),
713+ ):
714+ _apply_supervisor_image_update (
715+ ws ,
716+ channel = "beta" ,
717+ minimum_version = "2026.08.0" ,
718+ deadline = 1.0 ,
719+ timeout = 1.0 ,
720+ )
721+
722+ ws .reconnect .assert_called_once_with (deadline = 1.0 )
723+ sleep .assert_called_once_with (0.25 )
724+
725+
726+ def test_supervisor_update_retry_skips_sleep_when_budget_expires () -> None :
727+ """An exhausted Supervisor retry budget performs no backoff sleep."""
728+ ws = Mock ()
729+ with (
730+ patch (
731+ "tests.haos_image_build.build_image.time.monotonic" ,
732+ side_effect = [0.0 , 0.0 , 1.0 ],
733+ ),
734+ patch (
735+ "tests.haos_image_build.build_image._wait_supervisor_ready" ,
736+ side_effect = ConnectionError ("restart" ),
737+ ),
738+ patch ("tests.haos_image_build.build_image.time.sleep" ) as sleep ,
739+ pytest .raises (TimeoutError , match = "beta-image deadline" ),
740+ ):
741+ _apply_supervisor_image_update (
742+ ws ,
743+ channel = "beta" ,
744+ minimum_version = "2026.08.0" ,
745+ deadline = 1.0 ,
746+ timeout = 1.0 ,
747+ )
748+
749+ ws .reconnect .assert_called_once_with (deadline = 1.0 )
750+ sleep .assert_not_called ()
751+
752+
656753def test_wait_rejects_terminal_supervisor_error_without_retry () -> None :
657754 """A terminal Supervisor command error propagates immediately."""
658755 ws = Mock ()
@@ -1192,7 +1289,7 @@ def test_wait_core_version_reports_non_convergence() -> None:
11921289 with (
11931290 patch (
11941291 "tests.haos_image_build.build_image.time.monotonic" ,
1195- side_effect = [0.0 , 0.0 , 1.0 , 1.0 ],
1292+ side_effect = [0.0 , 0.0 , 0.5 , 1.0 , 1.0 ],
11961293 ),
11971294 patch ("tests.haos_image_build.build_image.time.sleep" ) as sleep ,
11981295 pytest .raises (TimeoutError ) as exc_info ,
@@ -1203,8 +1300,27 @@ def test_wait_core_version_reports_non_convergence() -> None:
12031300 assert "expected='2026.8.3'" in message
12041301 assert repr (old_info ) in message
12051302 ws .reconnect .assert_called_once_with (deadline = 1.0 )
1206- ws .supervisor_api .assert_called_once_with ("/core/info" , method = "get" , timeout = 30.0 )
1207- sleep .assert_called_once_with (0.0 )
1303+ ws .supervisor_api .assert_called_once_with ("/core/info" , method = "get" , timeout = 0.5 )
1304+ sleep .assert_not_called ()
1305+
1306+
1307+ def test_wait_core_version_skips_probe_after_reconnect_exhausts_budget () -> None :
1308+ """No Core version probe starts after reconnect consumes the deadline."""
1309+ ws = Mock ()
1310+
1311+ with (
1312+ patch (
1313+ "tests.haos_image_build.build_image.time.monotonic" ,
1314+ side_effect = [0.0 , 0.0 , 1.0 , 1.0 , 1.0 ],
1315+ ),
1316+ patch ("tests.haos_image_build.build_image.time.sleep" ) as sleep ,
1317+ pytest .raises (TimeoutError , match = "Core version check" ),
1318+ ):
1319+ _wait_core_version (ws , "2026.8.3" , timeout = 1.0 )
1320+
1321+ ws .reconnect .assert_called_once_with (deadline = 1.0 )
1322+ ws .supervisor_api .assert_not_called ()
1323+ sleep .assert_not_called ()
12081324
12091325
12101326def test_configure_beta_variant_polls_after_blank_unknown_core_update_error () -> None :
0 commit comments