Skip to content

Commit 8554263

Browse files
authored
fix(ble): fix race condition in test_ble_driver_connect_stream (#825)
2 parents c7c0ec9 + d4e99c4 commit 8554263

6 files changed

Lines changed: 75 additions & 3 deletions

File tree

python/packages/jumpstarter-driver-ble/jumpstarter_driver_ble/driver_test.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import anyio
44
import pytest
5+
from jumpstarter_testing.eventually import eventually
56

67
from .driver import BleWriteNotifyStream, _ble_notify_handler
78
from jumpstarter.common.utils import serve
@@ -76,7 +77,11 @@ def test_ble_driver_connect_stream():
7677
with client.stream() as stream:
7778
# Send data through the stream
7879
stream.send(b"hello")
79-
mock_client.write_gatt_char.assert_called()
80+
81+
# stream.send() only guarantees data was written to the
82+
# gRPC transport, not that the server has called
83+
# write_gatt_char yet — poll until it has.
84+
eventually(mock_client.write_gatt_char.assert_called)
8085

8186
# Verify start_notify was called for the notify characteristic
8287
mock_client.start_notify.assert_called_once()

python/packages/jumpstarter-driver-ble/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ requires = ["hatchling", "hatch-vcs", "hatch-pin-jumpstarter"]
3636
build-backend = "hatchling.build"
3737

3838
[dependency-groups]
39-
dev = ["pytest-anyio>=0.0.0", "pytest-cov>=6.0.0", "pytest>=8.3.3"]
39+
dev = ["jumpstarter-testing", "pytest-anyio>=0.0.0", "pytest-cov>=6.0.0", "pytest>=8.3.3"]
4040

4141
[tool.hatch.build.hooks.pin_jumpstarter]
4242
name = "pin_jumpstarter"
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from .eventually import eventually
12
from .pytest import JumpstarterTest
23

3-
__all__ = ["JumpstarterTest"]
4+
__all__ = ["JumpstarterTest", "eventually"]
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import time
2+
3+
4+
def eventually(fn, *, timeout=5, interval=0.05):
5+
"""Poll ``fn`` until it succeeds or *timeout* seconds elapse.
6+
7+
Similar to Ginkgo's ``Eventually``: calls *fn* repeatedly, catching
8+
``AssertionError`` and ``Exception``, sleeping *interval* seconds between
9+
attempts. If *fn* does not succeed before *timeout*, the last captured
10+
exception is re-raised.
11+
12+
Args:
13+
fn: A callable (typically a lambda wrapping an assertion).
14+
timeout: Maximum time in seconds to keep retrying (default 5).
15+
interval: Time in seconds between attempts (default 0.05).
16+
17+
Example::
18+
19+
from jumpstarter_testing import eventually
20+
21+
mock.write_gatt_char = AsyncMock()
22+
stream.send(b"hello")
23+
eventually(mock.write_gatt_char.assert_called)
24+
"""
25+
deadline = time.monotonic() + timeout
26+
while True:
27+
try:
28+
fn()
29+
return
30+
except (AssertionError, Exception):
31+
if time.monotonic() >= deadline:
32+
raise
33+
time.sleep(interval)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import pytest
2+
3+
from jumpstarter_testing.eventually import eventually
4+
5+
6+
def test_eventually_succeeds_immediately():
7+
"""Callable that passes on the first try returns immediately."""
8+
called = []
9+
def fn():
10+
called.append(1)
11+
eventually(fn, timeout=1)
12+
assert len(called) == 1
13+
14+
15+
def test_eventually_retries_until_success():
16+
"""Callable that fails initially but succeeds after a few attempts."""
17+
counter = {"n": 0}
18+
def fn():
19+
counter["n"] += 1
20+
if counter["n"] < 3:
21+
raise AssertionError("not yet")
22+
eventually(fn, timeout=5, interval=0.01)
23+
assert counter["n"] == 3
24+
25+
26+
def test_eventually_raises_on_timeout():
27+
"""Callable that never succeeds raises the last error after timeout."""
28+
def fn():
29+
raise AssertionError("always fails")
30+
with pytest.raises(AssertionError, match="always fails"):
31+
eventually(fn, timeout=0.1, interval=0.02)

python/uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)