@@ -62,7 +62,7 @@ def test_supervisor_api_shares_receive_deadline_across_frames() -> None:
6262
6363 with patch (
6464 "tests.haos_image_build.build_image.time.monotonic" ,
65- side_effect = [10.0 , 10.5 , 10.5 , 10.5 , 11.0 , 12.5 ],
65+ side_effect = [10.0 , 10.5 , 10.5 , 10.5 , 10.5 , 11.0 , 12.5 , 12.5 ],
6666 ):
6767 assert ws .supervisor_api ("/supervisor/info" , timeout = 5.0 ) == {}
6868
@@ -133,6 +133,51 @@ def stalled_send(_: str) -> None:
133133 assert ws ._ws is None
134134
135135
136+ def test_send_completion_after_deadline_invalidates_socket () -> None :
137+ """A send completing at its deadline still has an unknown outcome."""
138+ ws = HAWebSocket (
139+ "http://127.0.0.1:18123" ,
140+ OAuthCredentials (access_token = "access" , refresh_token = "refresh" ),
141+ )
142+ socket = Mock ()
143+ ws ._ws = socket
144+
145+ class CompletedThread :
146+ def __init__ (self , * , target : Any , ** _ : Any ) -> None :
147+ self ._target = target
148+
149+ def start (self ) -> None :
150+ pass
151+
152+ def join (self , timeout : float | None = None ) -> None :
153+ del timeout
154+ self ._target ()
155+
156+ def is_alive (self ) -> bool :
157+ return False
158+
159+ with (
160+ patch (
161+ "tests.haos_image_build.build_image.time.monotonic" ,
162+ side_effect = [10.0 , 10.0 , 10.0 , 11.0 ],
163+ ),
164+ patch (
165+ "tests.haos_image_build.build_image.threading.Thread" ,
166+ CompletedThread ,
167+ ),
168+ pytest .raises (TimeoutError , match = "command outcome is unknown" ),
169+ ):
170+ ws ._send_with_deadline (
171+ "{}" ,
172+ deadline = 11.0 ,
173+ operation = "test supervisor send" ,
174+ )
175+
176+ socket .send .assert_called_once_with ("{}" )
177+ socket .close_socket .assert_called_once ()
178+ assert ws ._ws is None
179+
180+
136181def test_supervisor_api_queued_send_timeout_does_not_dispatch () -> None :
137182 """A queued worker is cancelled without reporting an unknown outcome."""
138183 ws = HAWebSocket (
@@ -707,7 +752,7 @@ def test_supervisor_update_retry_caps_sleep_to_remaining_budget() -> None:
707752 ConnectionError ("restart" ),
708753 _SupervisorReadinessTimeout ("done" ),
709754 ],
710- ),
755+ ) as wait_ready ,
711756 patch ("tests.haos_image_build.build_image.time.sleep" ) as sleep ,
712757 pytest .raises (_SupervisorReadinessTimeout , match = "done" ),
713758 ):
@@ -719,6 +764,13 @@ def test_supervisor_update_retry_caps_sleep_to_remaining_budget() -> None:
719764 timeout = 1.0 ,
720765 )
721766
767+ ws .supervisor_api .assert_called_once_with (
768+ "/supervisor/update" , method = "post" , timeout = 1.0
769+ )
770+ assert [
771+ wait_call .kwargs ["update_timeout" ]
772+ for wait_call in wait_ready .call_args_list
773+ ] == [1.0 , 0.0 ]
722774 ws .reconnect .assert_called_once_with (deadline = 1.0 )
723775 sleep .assert_called_once_with (0.25 )
724776
@@ -734,7 +786,7 @@ def test_supervisor_update_retry_skips_sleep_when_budget_expires() -> None:
734786 patch (
735787 "tests.haos_image_build.build_image._wait_supervisor_ready" ,
736788 side_effect = ConnectionError ("restart" ),
737- ),
789+ ) as wait_ready ,
738790 patch ("tests.haos_image_build.build_image.time.sleep" ) as sleep ,
739791 pytest .raises (TimeoutError , match = "beta-image deadline" ),
740792 ):
@@ -746,6 +798,14 @@ def test_supervisor_update_retry_skips_sleep_when_budget_expires() -> None:
746798 timeout = 1.0 ,
747799 )
748800
801+ ws .supervisor_api .assert_called_once_with (
802+ "/supervisor/update" , method = "post" , timeout = 1.0
803+ )
804+ wait_ready .assert_called_once_with (
805+ update_timeout = 1.0 ,
806+ expected_channel = "beta" ,
807+ minimum_version = "2026.08.0" ,
808+ )
749809 ws .reconnect .assert_called_once_with (deadline = 1.0 )
750810 sleep .assert_not_called ()
751811
@@ -1304,6 +1364,42 @@ def test_wait_core_version_reports_non_convergence() -> None:
13041364 sleep .assert_not_called ()
13051365
13061366
1367+ def test_wait_core_version_rejects_matching_response_at_deadline () -> None :
1368+ """A matching Core response received at the deadline is not accepted."""
1369+ ws = HAWebSocket (
1370+ "http://127.0.0.1:18123" ,
1371+ OAuthCredentials (access_token = "access" , refresh_token = "refresh" ),
1372+ )
1373+ socket = Mock ()
1374+ socket .recv .return_value = json .dumps (
1375+ {
1376+ "id" : 1 ,
1377+ "type" : "result" ,
1378+ "success" : True ,
1379+ "result" : _core_info ("2026.8.3" ),
1380+ }
1381+ )
1382+ ws ._ws = socket
1383+
1384+ with (
1385+ patch (
1386+ "tests.haos_image_build.build_image.time.monotonic" ,
1387+ side_effect = [0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 1.0 , 1.0 , 1.0 ],
1388+ ),
1389+ patch .object (ws , "reconnect" ) as reconnect ,
1390+ patch .object (ws , "_send_with_deadline" ) as send ,
1391+ patch ("tests.haos_image_build.build_image.time.sleep" ) as sleep ,
1392+ pytest .raises (TimeoutError ) as exc_info ,
1393+ ):
1394+ _wait_core_version (ws , "2026.8.3" , timeout = 1.0 )
1395+
1396+ assert "receive exceeded its deadline" in str (exc_info .value )
1397+ reconnect .assert_called_once_with (deadline = 1.0 )
1398+ send .assert_called_once ()
1399+ socket .recv .assert_called_once_with (timeout = 1.0 )
1400+ sleep .assert_not_called ()
1401+
1402+
13071403def test_wait_core_version_skips_probe_after_reconnect_exhausts_budget () -> None :
13081404 """No Core version probe starts after reconnect consumes the deadline."""
13091405 ws = Mock ()
0 commit comments