272 lines
7.9 KiB
Python
272 lines
7.9 KiB
Python
|
|
'''
|
||
|
|
IB asyncio method-proxy regressions.
|
||
|
|
|
||
|
|
'''
|
||
|
|
from types import SimpleNamespace
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import trio
|
||
|
|
|
||
|
|
from piker.brokers.ib import api as ib_api
|
||
|
|
from piker.brokers.ib.api import (
|
||
|
|
MethodProxy,
|
||
|
|
relay_client_proxy_messages,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class FakeChannel:
|
||
|
|
'''
|
||
|
|
Model the Trio side of an asyncio linked-task channel.
|
||
|
|
|
||
|
|
'''
|
||
|
|
def __init__(self) -> None:
|
||
|
|
self.requests: list[tuple] = []
|
||
|
|
(
|
||
|
|
self._tx,
|
||
|
|
self._rx,
|
||
|
|
) = trio.open_memory_channel[Any](10)
|
||
|
|
|
||
|
|
async def send(self, msg: tuple) -> None:
|
||
|
|
'''
|
||
|
|
Capture one Trio-to-asyncio method request.
|
||
|
|
|
||
|
|
'''
|
||
|
|
self.requests.append(msg)
|
||
|
|
|
||
|
|
async def receive(self) -> Any:
|
||
|
|
'''
|
||
|
|
Receive one simulated asyncio-to-Trio response.
|
||
|
|
|
||
|
|
'''
|
||
|
|
return await self._rx.receive()
|
||
|
|
|
||
|
|
def closed(self) -> bool:
|
||
|
|
'''
|
||
|
|
Report an open channel for the lifetime of each test.
|
||
|
|
|
||
|
|
'''
|
||
|
|
return False
|
||
|
|
|
||
|
|
async def respond(self, msg: Any) -> None:
|
||
|
|
'''
|
||
|
|
Send one response or status event to the proxy relay.
|
||
|
|
|
||
|
|
'''
|
||
|
|
await self._tx.send(msg)
|
||
|
|
|
||
|
|
async def close_responses(self) -> None:
|
||
|
|
'''
|
||
|
|
Close the simulated asyncio-to-Trio response channel.
|
||
|
|
|
||
|
|
'''
|
||
|
|
await self._tx.aclose()
|
||
|
|
|
||
|
|
|
||
|
|
def test_method_proxy_routes_after_idle_status_events() -> None:
|
||
|
|
'''
|
||
|
|
Idle IB status traffic must not break first-symbol qualification.
|
||
|
|
|
||
|
|
``open_client_proxy()`` previously broadcast a one-slot channel
|
||
|
|
to an always-running status relay and an idle method receiver.
|
||
|
|
Status events advanced only the relay, so a first order for an
|
||
|
|
uncached symbol raised ``Lagged`` before receiving
|
||
|
|
``get_sym_details()``. Feed several idle events through the
|
||
|
|
sole-reader relay, then start two method calls and return their
|
||
|
|
responses in reverse order. Both exact results prove status
|
||
|
|
traffic cannot overrun a dormant caller and concurrent responses
|
||
|
|
remain correlated by request ID.
|
||
|
|
|
||
|
|
'''
|
||
|
|
async def main() -> None:
|
||
|
|
chan = FakeChannel()
|
||
|
|
proxy = MethodProxy(
|
||
|
|
chan,
|
||
|
|
event_table={},
|
||
|
|
asyncio_ns=SimpleNamespace(),
|
||
|
|
)
|
||
|
|
results: dict[str, str] = {}
|
||
|
|
|
||
|
|
async def call(name: str) -> None:
|
||
|
|
results[name] = await proxy._run_method(meth=name)
|
||
|
|
|
||
|
|
async with trio.open_nursery() as nursery:
|
||
|
|
nursery.start_soon(
|
||
|
|
relay_client_proxy_messages,
|
||
|
|
chan,
|
||
|
|
proxy,
|
||
|
|
)
|
||
|
|
for i in range(3):
|
||
|
|
await chan.respond((
|
||
|
|
'event',
|
||
|
|
{'reason': f'farm-status-{i}'},
|
||
|
|
))
|
||
|
|
|
||
|
|
nursery.start_soon(call, 'first')
|
||
|
|
nursery.start_soon(call, 'second')
|
||
|
|
with trio.fail_after(0.1):
|
||
|
|
while len(chan.requests) < 2:
|
||
|
|
await trio.lowlevel.checkpoint()
|
||
|
|
|
||
|
|
mids: dict[str, int] = {
|
||
|
|
meth: mid
|
||
|
|
for meth, _kwargs, mid in chan.requests
|
||
|
|
}
|
||
|
|
await chan.respond({
|
||
|
|
'mid': mids['second'],
|
||
|
|
'result': 'second-result',
|
||
|
|
})
|
||
|
|
await chan.respond({
|
||
|
|
'mid': mids['first'],
|
||
|
|
'result': 'first-result',
|
||
|
|
})
|
||
|
|
with trio.fail_after(0.1):
|
||
|
|
while len(results) < 2:
|
||
|
|
await trio.lowlevel.checkpoint()
|
||
|
|
nursery.cancel_scope.cancel()
|
||
|
|
|
||
|
|
assert results == {
|
||
|
|
'first': 'first-result',
|
||
|
|
'second': 'second-result',
|
||
|
|
}
|
||
|
|
|
||
|
|
trio.run(main)
|
||
|
|
|
||
|
|
|
||
|
|
def test_method_proxy_relay_accepts_clean_eof() -> None:
|
||
|
|
'''
|
||
|
|
Normal asyncio relay completion must not fail proxy teardown.
|
||
|
|
|
||
|
|
The sole-reader relay blocks in ``LinkedTaskChannel.receive()``
|
||
|
|
until the asyncio task exits and closes its Trio send channel.
|
||
|
|
Close the fake response sender before entering the relay and
|
||
|
|
prove the resulting ``EndOfChannel`` is graceful completion.
|
||
|
|
|
||
|
|
'''
|
||
|
|
async def main() -> None:
|
||
|
|
chan = FakeChannel()
|
||
|
|
proxy = MethodProxy(
|
||
|
|
chan,
|
||
|
|
event_table={},
|
||
|
|
asyncio_ns=SimpleNamespace(),
|
||
|
|
)
|
||
|
|
await chan.close_responses()
|
||
|
|
with trio.fail_after(0.1):
|
||
|
|
await relay_client_proxy_messages(chan, proxy)
|
||
|
|
|
||
|
|
trio.run(main)
|
||
|
|
|
||
|
|
|
||
|
|
def test_method_proxy_drops_cancelled_call_response() -> None:
|
||
|
|
'''
|
||
|
|
A cancelled symbol search must not skew the next method response.
|
||
|
|
|
||
|
|
Search timeouts can cancel a Trio caller after its request
|
||
|
|
reaches the asyncio client. Wait until one request is sent,
|
||
|
|
cancel its caller, and then deliver the orphaned response. The
|
||
|
|
sole-reader relay must drop that stale ``mid`` without failing; a
|
||
|
|
subsequent call must still receive its own exact response.
|
||
|
|
|
||
|
|
'''
|
||
|
|
async def main() -> None:
|
||
|
|
chan = FakeChannel()
|
||
|
|
proxy = MethodProxy(
|
||
|
|
chan,
|
||
|
|
event_table={},
|
||
|
|
asyncio_ns=SimpleNamespace(),
|
||
|
|
)
|
||
|
|
call_scope = trio.CancelScope()
|
||
|
|
cancelled = trio.Event()
|
||
|
|
result: list[str] = []
|
||
|
|
|
||
|
|
async def cancelled_call() -> None:
|
||
|
|
with call_scope:
|
||
|
|
try:
|
||
|
|
await proxy._run_method(meth='cancelled')
|
||
|
|
finally:
|
||
|
|
cancelled.set()
|
||
|
|
|
||
|
|
async def next_call() -> None:
|
||
|
|
value = await proxy._run_method(meth='next')
|
||
|
|
result.append(value)
|
||
|
|
|
||
|
|
async with trio.open_nursery() as nursery:
|
||
|
|
nursery.start_soon(
|
||
|
|
relay_client_proxy_messages,
|
||
|
|
chan,
|
||
|
|
proxy,
|
||
|
|
)
|
||
|
|
nursery.start_soon(cancelled_call)
|
||
|
|
with trio.fail_after(0.1):
|
||
|
|
while len(chan.requests) < 1:
|
||
|
|
await trio.lowlevel.checkpoint()
|
||
|
|
cancelled_mid: int = chan.requests[0][2]
|
||
|
|
call_scope.cancel()
|
||
|
|
with trio.fail_after(0.1):
|
||
|
|
await cancelled.wait()
|
||
|
|
assert proxy._pending == {}
|
||
|
|
assert (
|
||
|
|
proxy._request_methods[cancelled_mid]
|
||
|
|
==
|
||
|
|
'cancelled'
|
||
|
|
)
|
||
|
|
|
||
|
|
await chan.respond({
|
||
|
|
'mid': cancelled_mid,
|
||
|
|
'result': 'stale',
|
||
|
|
})
|
||
|
|
with trio.fail_after(0.1):
|
||
|
|
while cancelled_mid in proxy._request_methods:
|
||
|
|
await trio.lowlevel.checkpoint()
|
||
|
|
nursery.start_soon(next_call)
|
||
|
|
with trio.fail_after(0.1):
|
||
|
|
while len(chan.requests) < 2:
|
||
|
|
await trio.lowlevel.checkpoint()
|
||
|
|
next_mid: int = chan.requests[1][2]
|
||
|
|
await chan.respond({
|
||
|
|
'mid': next_mid,
|
||
|
|
'result': 'fresh',
|
||
|
|
})
|
||
|
|
with trio.fail_after(0.1):
|
||
|
|
while not result:
|
||
|
|
await trio.lowlevel.checkpoint()
|
||
|
|
nursery.cancel_scope.cancel()
|
||
|
|
|
||
|
|
assert result == ['fresh']
|
||
|
|
|
||
|
|
trio.run(main)
|
||
|
|
|
||
|
|
|
||
|
|
def test_method_proxy_warns_when_metadata_is_evicted(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
'''
|
||
|
|
Unresolved metadata eviction must remain operator-visible.
|
||
|
|
|
||
|
|
Cancelled calls whose asyncio responses never arrive retain a
|
||
|
|
bounded method name solely for later stale-response diagnostics.
|
||
|
|
Fill that table beyond its limit and prove the oldest entry is
|
||
|
|
evicted with its request ID, method name, and configured bound in
|
||
|
|
the warning.
|
||
|
|
|
||
|
|
'''
|
||
|
|
proxy = MethodProxy(
|
||
|
|
FakeChannel(),
|
||
|
|
event_table={},
|
||
|
|
asyncio_ns=SimpleNamespace(),
|
||
|
|
)
|
||
|
|
warnings: list[str] = []
|
||
|
|
monkeypatch.setattr(ib_api.log, 'warning', warnings.append)
|
||
|
|
|
||
|
|
for mid in range(257):
|
||
|
|
proxy._track_request_method(mid, f'method-{mid}')
|
||
|
|
|
||
|
|
assert len(proxy._request_methods) == 256
|
||
|
|
assert 0 not in proxy._request_methods
|
||
|
|
assert warnings == [
|
||
|
|
'Evicting unresolved method-request metadata,\n'
|
||
|
|
'mid: 0\n'
|
||
|
|
"meth: 'method-0'\n"
|
||
|
|
'pending metadata limit: 256\n'
|
||
|
|
]
|