piker/tests/providers/test_ib_wrapper_timeout.py

184 lines
5.6 KiB
Python

'''
Offline packet-idle timer regressions using real eventkit events.
'''
import asyncio
from datetime import datetime
from types import SimpleNamespace
from eventkit import Event
from ib_async import wrapper as upstream
import pytest
from piker.brokers.ib import api
@pytest.fixture
def timer(monkeypatch):
'''
Drive real asyncio TimerHandles with a deterministic wall clock.
'''
class ClockLoop:
now = 1_800_000_000.0
def __init__(self):
self.handles = []
def get_debug(self):
return False
def _timer_handle_cancelled(self, handle):
pass
def call_exception_handler(self, context):
raise context['exception']
def call_later(self, delay, callback):
handle = asyncio.TimerHandle(
self.now + delay, callback, (), self,
)
self.handles.append(handle)
return handle
def advance(self, seconds):
target = self.now + seconds
while self.handles:
handle = min(self.handles, key=lambda h: h.when())
if handle.when() > target:
break
self.handles.remove(handle)
self.now = handle.when()
if not handle.cancelled():
handle._run()
self.now = target
loop = ClockLoop()
class ClockDateTime(datetime):
@classmethod
def now(cls, tz=None):
return cls.fromtimestamp(loop.now, tz)
monkeypatch.setattr(upstream, 'datetime', ClockDateTime)
monkeypatch.setattr(upstream, 'getLoop', lambda: loop)
monkeypatch.setattr(api, 'getLoop', lambda: loop)
monkeypatch.setattr(
api.time, 'time_ns', lambda: int(loop.now * 1_000_000_000),
)
ib = SimpleNamespace(timeoutEvent=Event('timeoutEvent'))
wrapper = api.NonShittyWrapper(ib)
events = []
def record(diff):
events.append((diff, wrapper.lastTime))
ib.timeoutEvent.connect(record)
return loop, wrapper, events
def test_packet_timeout_reschedules_and_emits_once(timer):
'''
Upstream datetime subtraction raised TypeError after a packet
replaced NonShittyWrapper.lastTime with epoch nanoseconds.
Arm the real wrapper, deliver packets around the first deadline,
and drive its TimerHandles without sleeping or opening sockets.
New traffic must postpone expiry by the remaining idle interval;
the real eventkit listener must see seconds and the intact ns
timestamp exactly once, with the timer already disabled.
'''
loop, wrapper, events = timer
wrapper.setTimeout(5)
loop.advance(1)
wrapper.tcpDataArrived()
assert isinstance(wrapper.lastTime, int)
loop.advance(3)
wrapper.tcpDataArrived()
packet_time = wrapper.lastTime
loop.advance(1)
assert events == []
assert wrapper.lastTime == packet_time
assert wrapper._timeoutHandle.when() == loop.now + 4
loop.advance(4)
assert events == [(5.0, packet_time)]
assert wrapper._timeoutHandle is None
assert wrapper._timeout == 0
assert wrapper.lastTime == packet_time
wrapper._setTimer()
loop.advance(20)
assert events == [(5.0, packet_time)]
def test_datetime_timeout_and_sentinel(timer):
'''
Before any packet, Wrapper.setTimeout uses datetime timestamps.
The ns fix must preserve that upstream path and datetime.min's
sentinel short circuit. Run an armed pre-traffic timer to expiry,
then invoke the sentinel path and assert no callback is queued.
'''
loop, wrapper, events = timer
assert isinstance(wrapper.lastTime, datetime)
assert wrapper._timeoutHandle is None
wrapper.setTimeout(3)
loop.advance(3)
assert len(events) == 1
assert events[0][0] == 3.0
assert isinstance(events[0][1], datetime)
assert wrapper._timeoutHandle is None
wrapper.lastTime = datetime.min
wrapper._setTimer(2)
loop.advance(10)
assert len(events) == 1
assert wrapper.lastTime == datetime.min
assert wrapper._timeoutHandle is None
def test_rearm_disable_and_reentrant_listener(timer):
'''
Replacing or disabling a packet-idle timer must cancel its old
handle. Expiry must also finish teardown before timeoutEvent
listeners rearm it, otherwise the new timer can be lost.
Advance cancelled deadlines deterministically, then rearm from
a real eventkit listener and deliver another packet. Both arms
must emit once, with packet timestamps intact during emission.
'''
loop, wrapper, events = timer
wrapper.setTimeout(2)
wrapper.tcpDataArrived()
old = wrapper._timeoutHandle
wrapper.setTimeout(4)
assert old.cancelled()
replacement = wrapper._timeoutHandle
wrapper.tcpDataArrived()
wrapper.setTimeout(0)
assert replacement.cancelled()
assert wrapper._timeoutHandle is None
wrapper.tcpDataArrived()
loop.advance(10)
assert events == []
observed = []
def rearm(diff):
observed.append((wrapper._timeout, wrapper._timeoutHandle))
if len(observed) == 1:
wrapper.setTimeout(3)
wrapper.tcpDataArrived()
wrapper.ib.timeoutEvent.connect(rearm)
wrapper.setTimeout(2)
wrapper.tcpDataArrived()
first_packet = wrapper.lastTime
loop.advance(2)
second_packet = wrapper.lastTime
assert wrapper._timeout == 3
assert wrapper._timeoutHandle is not None
loop.advance(3)
loop.advance(10)
assert events == [(2.0, first_packet), (3.0, second_packet)]
assert observed == [(0, None), (0, None)]
assert wrapper.lastTime == second_packet