Skip to content

Commit 1fc9466

Browse files
committed
FIX: Timeouts
1 parent c69e693 commit 1fc9466

4 files changed

Lines changed: 85 additions & 28 deletions

File tree

‎.github/workflows/ci.yaml‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ jobs:
5555
needs: cibuildwheel
5656
name: test wheels on ${{ matrix.os }} - py${{ matrix.python }}
5757
runs-on: ${{ matrix.os }}
58+
timeout-minutes: 45
5859
strategy:
5960
fail-fast: false
6061
matrix:
@@ -85,7 +86,7 @@ jobs:
8586
- name: Run pytest
8687
uses: ./.github/actions/retry-step
8788
with:
88-
command: pytest tests/ --cov=mne_lsl --cov-report=xml --cov-config=pyproject.toml -s
89+
command: pytest tests/ --cov=mne_lsl --cov-report=xml --cov-config=pyproject.toml -s --reruns 3 --reruns-delay 2 --rerun-except 'from pytest-timeout'
8990
retry_error_codes: "3,127,134,139"
9091
env:
9192
MNE_LSL_LOG_LEVEL: DEBUG
@@ -98,6 +99,7 @@ jobs:
9899
needs: cibuildwheel
99100
name: test wheels with minimum mne
100101
runs-on: ubuntu-latest
102+
timeout-minutes: 45
101103
steps:
102104
- uses: actions/checkout@v7
103105
- uses: astral-sh/setup-uv@v7
@@ -119,7 +121,7 @@ jobs:
119121
- name: Run pytest
120122
uses: ./.github/actions/retry-step
121123
with:
122-
command: pytest tests/ --cov=mne_lsl --cov-report=xml --cov-config=pyproject.toml -s
124+
command: pytest tests/ --cov=mne_lsl --cov-report=xml --cov-config=pyproject.toml -s --reruns 3 --reruns-delay 2 --rerun-except 'from pytest-timeout'
123125
retry_error_codes: "3,127,134,139"
124126
env:
125127
MNE_LSL_LOG_LEVEL: DEBUG
@@ -132,6 +134,7 @@ jobs:
132134
needs: cibuildwheel
133135
name: test pip pre-release - py${{ matrix.python }}
134136
runs-on: ubuntu-latest
137+
timeout-minutes: 45
135138
strategy:
136139
fail-fast: false
137140
matrix:
@@ -160,7 +163,7 @@ jobs:
160163
- name: Run pytest
161164
uses: ./.github/actions/retry-step
162165
with:
163-
command: pytest tests/ --cov=mne_lsl --cov-report=xml --cov-config=pyproject.toml -s
166+
command: pytest tests/ --cov=mne_lsl --cov-report=xml --cov-config=pyproject.toml -s --reruns 3 --reruns-delay 2 --rerun-except 'from pytest-timeout'
164167
retry_error_codes: "3,134,139"
165168
env:
166169
MNE_LSL_LOG_LEVEL: DEBUG
@@ -172,6 +175,7 @@ jobs:
172175
test-editable:
173176
name: test editable install
174177
runs-on: ubuntu-latest
178+
timeout-minutes: 45
175179
steps:
176180
- uses: actions/checkout@v7
177181
with:
@@ -186,7 +190,7 @@ jobs:
186190
- name: Run pytest
187191
uses: ./.github/actions/retry-step
188192
with:
189-
command: pytest tests/ --cov=mne_lsl --cov-report=xml --cov-config=pyproject.toml -s
193+
command: pytest tests/ --cov=mne_lsl --cov-report=xml --cov-config=pyproject.toml -s --reruns 3 --reruns-delay 2 --rerun-except 'from pytest-timeout'
190194
retry_error_codes: "3,134,139"
191195
env:
192196
MNE_LSL_LOG_LEVEL: DEBUG
@@ -245,6 +249,7 @@ jobs:
245249
needs: cibuildwheel
246250
name: build documentation
247251
runs-on: ubuntu-latest
252+
timeout-minutes: 45
248253
steps:
249254
- run: sudo apt update && sudo apt install -y optipng
250255
- uses: actions/checkout@v7

‎pyproject.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ test = [
3535
'pylabrecorder>=0.3.0',
3636
'pytest-cov',
3737
'pytest-randomly',
38+
'pytest-rerunfailures>=10.2', # '--rerun-except'
3839
'pytest-timeout',
3940
'pytest>=8.0',
4041
'pyxdf',
@@ -167,6 +168,9 @@ omit = [
167168
addopts = ['--color=yes', '--cov-report=', '--durations=20', '--junit-xml=junit-results.xml', '--strict-config', '--tb=short', '-ra', '-v']
168169
junit_family = 'xunit2'
169170
minversion = '8.0'
171+
# the slowest test is ~30 seconds on CI, anything above 3 minutes is a hang. Timeouts are
172+
# not retried in CI ('--rerun-except'), thus the generous margin for slow runners.
173+
timeout = 180
170174

171175
[tool.ruff]
172176
extend-exclude = ["doc/_examples/*.py"]

‎tests/stream/test_epochs.py‎

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from numpy.testing import assert_allclose, assert_array_equal
1414

1515
from mne_lsl.datasets import testing
16-
from mne_lsl.lsl import StreamInfo, StreamOutlet
16+
from mne_lsl.lsl import StreamInfo, StreamOutlet, local_clock
1717
from mne_lsl.stream import EpochsStream, StreamLSL
1818
from mne_lsl.stream.epochs import (
1919
_check_baseline,
@@ -34,6 +34,41 @@
3434
from numpy.typing import NDArray
3535

3636

37+
def _wait_for_epochs(
38+
epochs: EpochsStream,
39+
n: int,
40+
*,
41+
timeout: float = 20,
42+
settle: float = 1.5,
43+
interval: float = 0.1,
44+
) -> None:
45+
"""Wait until 'n' epochs were acquired, then check that the count is stable.
46+
47+
Unlike ``while epochs.n_new_epochs != n``, which exits on the first observation
48+
matching 'n', this reports an over-count instead of hiding it.
49+
"""
50+
manual = epochs._acquisition_delay is None
51+
start = time.monotonic()
52+
while epochs.n_new_epochs < n:
53+
if timeout < time.monotonic() - start:
54+
raise AssertionError(
55+
f"Only {epochs.n_new_epochs} epoch(s) were acquired within {timeout} "
56+
f"seconds, expected {n}. Events in the buffer: {epochs.events}."
57+
)
58+
if manual:
59+
epochs.acquire()
60+
time.sleep(interval)
61+
start = time.monotonic()
62+
while time.monotonic() - start < settle:
63+
if manual:
64+
epochs.acquire()
65+
time.sleep(interval)
66+
assert epochs.n_new_epochs == n, (
67+
f"{epochs.n_new_epochs} epoch(s) were acquired, expected {n}. Events in the "
68+
f"buffer: {epochs.events}."
69+
)
70+
71+
3772
def test_ensure_event_id() -> None:
3873
"""Test validation of event dictionary."""
3974
assert _ensure_event_id(5, None) == {"5": 5}
@@ -775,7 +810,9 @@ def test_epochs_with_irregular_numerical_event_stream(
775810
n = epochs.n_new_epochs
776811
data = epochs.get_data()
777812
assert_allclose(data[:-n, :, :], np.zeros((10 - n, data.shape[1], data.shape[2])))
778-
data_channels = data[-n:, 1:-1, 2:-2] # give 2 sample of jitter
813+
# the epoch spans exactly the 100 samples set to 101, so trim 10 samples per side
814+
# for the timestamp jitter. A misaligned epoch still leaves a long run of 0.
815+
data_channels = data[-n:, 1:-1, 10:-10]
779816
assert_allclose(data_channels, np.ones(data_channels.shape) * 101)
780817
epochs.disconnect()
781818
stream.disconnect()
@@ -1076,15 +1113,9 @@ def test_epochs_single_event(
10761113
time.sleep(0.5)
10771114
epochs.acquire()
10781115
assert epochs.n_new_epochs == 0
1079-
# push a single event, then loop until the epoch is acquired or timeout. The epoch
1080-
# needs tmax=1.5 seconds of data after the event before it can be completed, so the
1081-
# loop must wait at least that long.
1116+
# push a single event; the epoch needs tmax=1.5 seconds of data after it to complete
10821117
outlet_marker.push_sample(np.array([1], dtype=sinfo.dtype))
1083-
start = time.monotonic()
1084-
while epochs.n_new_epochs == 0 and time.monotonic() - start < 5:
1085-
epochs.acquire()
1086-
time.sleep(0.2)
1087-
assert epochs.n_new_epochs == 1
1118+
_wait_for_epochs(epochs, 1)
10881119
assert event_stream.n_new_samples == 1
10891120
epochs.disconnect()
10901121
event_stream.disconnect()
@@ -1122,10 +1153,17 @@ def test_epochs_with_more_events_than_buffer_size(
11221153
for _ in range(5):
11231154
outlet_marker.push_sample(np.array([1], dtype=sinfo.dtype))
11241155
time.sleep(0.1)
1125-
# wait for the event stream and data stream to buffer all events, so that a
1126-
# single acquire() call sees all 5 events at once and triggers the warning.
1127-
# The event_stream bufsize=10 ensures all 5 events are retained in its ringbuffer.
1128-
time.sleep(1.0)
1156+
# to trigger the warning, a single acquire() must see all 5 events at once, i.e. the
1157+
# event stream pulled all 5 markers and the data stream buffered past the last one.
1158+
# Wait on both rather than on a fixed sleep, which a loaded runner can outlast.
1159+
ts_last_event = local_clock()
1160+
start = time.monotonic()
1161+
while (
1162+
event_stream.n_new_samples < 5 or stream._timestamps[-1] <= ts_last_event
1163+
) and time.monotonic() - start < 20:
1164+
time.sleep(0.1)
1165+
assert event_stream.n_new_samples == 5
1166+
assert ts_last_event < stream._timestamps[-1]
11291167
with pytest.warns(RuntimeWarning, match="number of new epochs to add.*is greater"):
11301168
epochs.acquire()
11311169
epochs.disconnect()
@@ -1170,11 +1208,8 @@ def test_epochs_with_irregular_numerical_event_stream_and_event_id(
11701208
time.sleep(0.1)
11711209
outlet_marker.push_sample(np.array([1], dtype=sinfo.dtype))
11721210
time.sleep(0.1)
1173-
start = time.monotonic()
1174-
while epochs.n_new_epochs != 3 and time.monotonic() - start < 3:
1175-
epochs.acquire()
1176-
time.sleep(0.5)
11771211
# check that we got 3 epochs on the code '1'
1212+
_wait_for_epochs(epochs, 3)
11781213
events = epochs.events
11791214
assert_array_equal(events[events != 0], [1, 1, 1])
11801215
epochs.disconnect()
@@ -1208,7 +1243,9 @@ def test_epochs_with_irregular_numerical_event_stream_with_2_ch_and_event_id(
12081243
10, name=mock_lsl_stream.name, source_id=mock_lsl_stream.source_id
12091244
).connect(acquisition_delay=0.1)
12101245
sinfo = outlet_marker_3_channel.get_sinfo()
1211-
event_stream = StreamLSL(5, name=sinfo.name, source_id=sinfo.source_id).connect(
1246+
# the buffer must hold all 6 events pushed below, else the events seen by acquire()
1247+
# depend on how fast the acquisition thread drains the inlet.
1248+
event_stream = StreamLSL(10, name=sinfo.name, source_id=sinfo.source_id).connect(
12121249
acquisition_delay=0.1
12131250
)
12141251
epochs = EpochsStream(
@@ -1245,12 +1282,8 @@ def test_epochs_with_irregular_numerical_event_stream_with_2_ch_and_event_id(
12451282
time.sleep(0.1)
12461283
outlet_marker_3_channel.push_sample(np.array([1, 2, 0], dtype=sinfo.dtype))
12471284
time.sleep(0.1)
1248-
start = time.monotonic()
1249-
while epochs.n_new_epochs != 2 and time.monotonic() - start < 3:
1250-
epochs.acquire()
1251-
time.sleep(0.5)
12521285
# check that we got 2 epochs on the code '1'
1253-
assert epochs.n_new_epochs == 2
1286+
_wait_for_epochs(epochs, 2)
12541287
events = epochs.events
12551288
assert_array_equal(events[events != 0], [1, 1])
12561289
epochs.disconnect()

‎uv.lock‎

Lines changed: 15 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)