Skip to content

Commit e147a94

Browse files
committed
fix(haos): reject post-deadline websocket results
1 parent e1e43e7 commit e147a94

2 files changed

Lines changed: 118 additions & 7 deletions

File tree

tests/haos_image_build/build_image.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -801,26 +801,37 @@ def send() -> None:
801801
worker = threading.Thread(target=send, name="haos-ws-send", daemon=True)
802802
worker.start()
803803
worker.join(max(0.0, deadline - time.monotonic()))
804-
if worker.is_alive():
804+
timed_out_after_dispatch = worker.is_alive()
805+
if timed_out_after_dispatch:
805806
with dispatch_lock:
806807
if not send_state["started"]:
807808
send_state["cancelled"] = True
808809
raise TimeoutError(
809810
f"{operation} exceeded its deadline before dispatch"
810811
)
812+
elif send_errors:
813+
raise send_errors[0]
814+
else:
815+
with dispatch_lock:
816+
dispatch_started = send_state["started"]
817+
if dispatch_started:
818+
try:
819+
_remaining_deadline_budget(deadline, operation)
820+
except TimeoutError:
821+
timed_out_after_dispatch = True
822+
823+
if timed_out_after_dispatch:
811824
try:
812825
connection.close_socket()
813826
except (OSError, RuntimeError) as exc:
814-
LOG.debug("WS close error after stalled send: %r", exc)
827+
LOG.debug("WS close error after timed-out send: %r", exc)
815828
finally:
816829
if self._ws is connection:
817830
self._ws = None
818831
raise TimeoutError(
819832
f"{operation} exceeded its deadline after dispatch; "
820833
"command outcome is unknown"
821834
)
822-
if send_errors:
823-
raise send_errors[0]
824835

825836
def supervisor_api(
826837
self,
@@ -871,6 +882,10 @@ def supervisor_api(
871882
endpoint=endpoint,
872883
)
873884
if result is not None:
885+
_remaining_deadline_budget(
886+
deadline,
887+
f"supervisor/api {method} {endpoint} receive",
888+
)
874889
return result
875890

876891

tests/src/unit/test_haos_supervisor_wait.py

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
136181
def 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+
13071403
def 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

Comments
 (0)