Skip to content

Commit 05f4686

Browse files
committed
fix(haos): enforce image build deadlines
1 parent d0f5ce3 commit 05f4686

2 files changed

Lines changed: 157 additions & 16 deletions

File tree

tests/haos_image_build/build_image.py

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -607,9 +607,17 @@ def _connect(self, *, deadline: float | None = None) -> None:
607607
auth_req = json.loads(self._recv_auth_frame(deadline))
608608
if auth_req.get("type") != "auth_required":
609609
raise RuntimeError(f"Unexpected WS handshake message: {auth_req}")
610-
self._ws.send(
611-
json.dumps({"type": "auth", "access_token": self._credentials.access_token})
610+
auth_message = json.dumps(
611+
{"type": "auth", "access_token": self._credentials.access_token}
612612
)
613+
if deadline is None:
614+
self._ws.send(auth_message)
615+
else:
616+
self._send_with_deadline(
617+
auth_message,
618+
deadline=deadline,
619+
operation="WebSocket authentication send",
620+
)
613621
auth_resp = json.loads(self._recv_auth_frame(deadline))
614622
if auth_resp.get("type") != "auth_ok":
615623
raise RuntimeError(f"WS auth rejected: {auth_resp}")
@@ -2034,7 +2042,13 @@ def _wait_core_version(
20342042
while time.monotonic() < deadline:
20352043
try:
20362044
ws.reconnect(deadline=deadline)
2037-
last_info = ws.supervisor_api("/core/info", method="get", timeout=30.0)
2045+
request_timeout = min(
2046+
30.0,
2047+
_remaining_deadline_budget(deadline, "Core version check"),
2048+
)
2049+
last_info = ws.supervisor_api(
2050+
"/core/info", method="get", timeout=request_timeout
2051+
)
20382052
except _SUPERVISOR_WAIT_TRANSIENT_ERRORS as exc:
20392053
if not _is_transient_supervisor_readiness_error(exc):
20402054
raise
@@ -2048,7 +2062,9 @@ def _wait_core_version(
20482062
last_info.get("version"),
20492063
expected_version,
20502064
)
2051-
time.sleep(max(0.0, min(5.0, deadline - time.monotonic())))
2065+
remaining = deadline - time.monotonic()
2066+
if remaining > 0:
2067+
time.sleep(min(5.0, remaining))
20522068

20532069
last_err_suffix = f"; last error: {last_error!r}" if last_error else ""
20542070
raise TimeoutError(
@@ -2085,10 +2101,10 @@ def _wait_supervisor_channel_metadata(
20852101
"""Wait for channel metadata and return whether Supervisor needs updating."""
20862102
last_info: dict[str, Any] | None = None
20872103
last_error: BaseException | None = None
2088-
while time.monotonic() < deadline:
2104+
while (request_timeout := deadline - time.monotonic()) > 0:
20892105
try:
20902106
last_info = ws.supervisor_api(
2091-
"/supervisor/info", method="get", timeout=30.0
2107+
"/supervisor/info", method="get", timeout=min(30.0, request_timeout)
20922108
)
20932109
except _SUPERVISOR_WAIT_TRANSIENT_ERRORS as exc:
20942110
if not _is_transient_supervisor_readiness_error(exc):
@@ -2102,7 +2118,9 @@ def _wait_supervisor_channel_metadata(
21022118
)
21032119
if reconnect_error is not None:
21042120
last_error = reconnect_error
2105-
time.sleep(5.0)
2121+
remaining = deadline - time.monotonic()
2122+
if remaining > 0:
2123+
time.sleep(min(5.0, remaining))
21062124
continue
21072125

21082126
latest = last_info.get("version_latest")
@@ -2120,7 +2138,9 @@ def _wait_supervisor_channel_metadata(
21202138
return False
21212139
if last_info.get("update_available"):
21222140
return True
2123-
time.sleep(5.0)
2141+
remaining = deadline - time.monotonic()
2142+
if remaining > 0:
2143+
time.sleep(min(5.0, remaining))
21242144

21252145
last_err_suffix = f"; last error: {last_error!r}" if last_error else ""
21262146
raise TimeoutError(
@@ -2172,7 +2192,12 @@ def _apply_supervisor_image_update(
21722192
context="after Supervisor update",
21732193
deadline=deadline,
21742194
)
2175-
time.sleep(5.0)
2195+
remaining = deadline - time.monotonic()
2196+
if remaining <= 0:
2197+
raise TimeoutError(
2198+
"Supervisor did not reconnect before the beta-image deadline"
2199+
) from exc
2200+
time.sleep(min(5.0, remaining))
21762201

21772202

21782203
def _configure_core_image_variant(

tests/src/unit/test_haos_supervisor_wait.py

Lines changed: 123 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
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+
368401
def 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

616655
def 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

640680
def 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+
656753
def 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

12101326
def test_configure_beta_variant_polls_after_blank_unknown_core_update_error() -> None:

0 commit comments

Comments
 (0)