528 lines
20 KiB
Python
528 lines
20 KiB
Python
|
|
"""Exhaustive sync↔async seam tests for PINK-on-DITAv2.
|
||
|
|
|
||
|
|
Tests every boundary where sync code meets async code:
|
||
|
|
1. BingxVenueAdapter._run() — 3 execution modes (no-loop, in-loop, already-ran)
|
||
|
|
2. BingxVenueAdapter.connect() -> async backend
|
||
|
|
3. kernel.process_intent() (sync) -> venue.submit() (sync) -> _run() -> async
|
||
|
|
4. PinkDirectRuntime.step() (async) -> kernel.process_intent() (sync)
|
||
|
|
5. launcher._maybe_close() inside/outside event loop
|
||
|
|
6. _backend_snapshot() HTTP timeout cascade
|
||
|
|
7. Thread safety: concurrent _run() calls, _last_snapshot races
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import concurrent.futures
|
||
|
|
import inspect
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
import unittest
|
||
|
|
from concurrent.futures import ThreadPoolExecutor
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
from typing import Any, List, Optional
|
||
|
|
from unittest import mock
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Seam 1: _run() execution modes
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
# We test the real _run() method directly by importing the module
|
||
|
|
from prod.clean_arch.dita_v2.bingx_venue import BingxVenueAdapter
|
||
|
|
|
||
|
|
def _make_adapter() -> BingxVenueAdapter:
|
||
|
|
"""Build a real BingxVenueAdapter for seam testing."""
|
||
|
|
from prod.bingx.config import BingxExecClientConfig
|
||
|
|
from prod.bingx.enums import BingxEnvironment
|
||
|
|
from prod.clean_arch.adapters.bingx_direct import BingxDirectExecutionAdapter
|
||
|
|
|
||
|
|
config = BingxExecClientConfig(
|
||
|
|
api_key="test", secret_key="test",
|
||
|
|
environment=BingxEnvironment.VST,
|
||
|
|
allow_mainnet=False,
|
||
|
|
recv_window_ms=5000,
|
||
|
|
default_leverage=1,
|
||
|
|
exchange_leverage_cap=3,
|
||
|
|
prefer_websocket=False,
|
||
|
|
sizing_mode="testnet",
|
||
|
|
journal_strategy="pink",
|
||
|
|
journal_db="dolphin_pink",
|
||
|
|
)
|
||
|
|
backend = BingxDirectExecutionAdapter(config)
|
||
|
|
return BingxVenueAdapter(backend=backend)
|
||
|
|
|
||
|
|
# Temporary adapter class so we can test _run() without making HTTP calls
|
||
|
|
class _DummyBackend:
|
||
|
|
"""Sync + async method surface for seam testing."""
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
self._call_count = 0
|
||
|
|
|
||
|
|
# Sync method
|
||
|
|
def sync_method(self, x: int = 1) -> int:
|
||
|
|
self._call_count += 1
|
||
|
|
return x * 2
|
||
|
|
|
||
|
|
# Async method
|
||
|
|
async def async_method(self, x: int = 1) -> int:
|
||
|
|
self._call_count += 1
|
||
|
|
await asyncio.sleep(0.001)
|
||
|
|
return x * 2
|
||
|
|
|
||
|
|
# Slow async method for timeout testing
|
||
|
|
async def slow_async_method(self, delay: float = 10.0) -> str:
|
||
|
|
self._call_count += 1
|
||
|
|
await asyncio.sleep(delay)
|
||
|
|
return "done"
|
||
|
|
|
||
|
|
# Coroutine that raises
|
||
|
|
async def failing_async_method(self) -> None:
|
||
|
|
self._call_count += 1
|
||
|
|
await asyncio.sleep(0.001)
|
||
|
|
raise ValueError("async failure")
|
||
|
|
|
||
|
|
# Method that IS a coroutine (not a function returning a coroutine)
|
||
|
|
async def coro_method(self) -> str:
|
||
|
|
return "coro"
|
||
|
|
|
||
|
|
class TestRunExecutionModes(unittest.TestCase):
|
||
|
|
"""Test all 3 _run() execution modes exhaustively."""
|
||
|
|
|
||
|
|
def setUp(self):
|
||
|
|
self.adapter = _make_adapter()
|
||
|
|
self.backend = _DummyBackend()
|
||
|
|
|
||
|
|
# --- Mode 1: Non-awaitable (sync method, pass through) ---
|
||
|
|
|
||
|
|
def test_sync_method_passthrough(self):
|
||
|
|
result = self.adapter._run(self.backend.sync_method(5))
|
||
|
|
self.assertEqual(result, 10)
|
||
|
|
self.assertEqual(self.backend._call_count, 1)
|
||
|
|
|
||
|
|
def test_sync_returns_none_passthrough(self):
|
||
|
|
result = self.adapter._run(None)
|
||
|
|
self.assertIsNone(result)
|
||
|
|
|
||
|
|
def test_sync_returns_false_passthrough(self):
|
||
|
|
result = self.adapter._run(False)
|
||
|
|
self.assertFalse(result)
|
||
|
|
|
||
|
|
def test_sync_returns_empty_list_passthrough(self):
|
||
|
|
result = self.adapter._run([])
|
||
|
|
self.assertEqual(result, [])
|
||
|
|
|
||
|
|
# --- Mode 2: Awaitable, no running loop (asyncio.run) ---
|
||
|
|
|
||
|
|
def test_async_method_no_loop(self):
|
||
|
|
result = self.adapter._run(self.backend.async_method(7))
|
||
|
|
self.assertEqual(result, 14)
|
||
|
|
self.assertEqual(self.backend._call_count, 1)
|
||
|
|
|
||
|
|
def test_async_method_no_loop_negative(self):
|
||
|
|
result = self.adapter._run(self.backend.async_method(-3))
|
||
|
|
self.assertEqual(result, -6)
|
||
|
|
|
||
|
|
def test_async_method_no_loop_zero(self):
|
||
|
|
result = self.adapter._run(self.backend.async_method(0))
|
||
|
|
self.assertEqual(result, 0)
|
||
|
|
|
||
|
|
def test_async_method_no_loop_large_input(self):
|
||
|
|
result = self.adapter._run(self.backend.async_method(1_000_000))
|
||
|
|
self.assertEqual(result, 2_000_000)
|
||
|
|
|
||
|
|
# --- Mode 3: Awaitable, inside running loop (ThreadPoolExecutor) ---
|
||
|
|
|
||
|
|
def test_async_method_inside_loop(self):
|
||
|
|
"""Call _run() from inside a running asyncio event loop."""
|
||
|
|
async def run_inside_loop():
|
||
|
|
return self.adapter._run(self.backend.async_method(11))
|
||
|
|
result = asyncio.run(run_inside_loop())
|
||
|
|
self.assertEqual(result, 22)
|
||
|
|
|
||
|
|
def test_async_method_inside_loop_multiple_calls(self):
|
||
|
|
async def run_inside_loop():
|
||
|
|
a = self.adapter._run(self.backend.async_method(1))
|
||
|
|
b = self.adapter._run(self.backend.async_method(2))
|
||
|
|
c = self.adapter._run(self.backend.async_method(3))
|
||
|
|
return a, b, c
|
||
|
|
a, b, c = asyncio.run(run_inside_loop())
|
||
|
|
self.assertEqual((a, b, c), (2, 4, 6))
|
||
|
|
|
||
|
|
def test_async_inside_sync_inside_async_nested(self):
|
||
|
|
"""Russian-doll nesting: sync -> async -> sync -> async."""
|
||
|
|
async def outer():
|
||
|
|
# Simulate what PinkDirectRuntime.step() does:
|
||
|
|
# step() is async, calls kernel.process_intent() which is sync,
|
||
|
|
# which calls venue.submit() which calls _run() on async backend
|
||
|
|
def middle_sync():
|
||
|
|
return self.adapter._run(self.backend.async_method(3))
|
||
|
|
return middle_sync()
|
||
|
|
result = asyncio.run(outer())
|
||
|
|
self.assertEqual(result, 6)
|
||
|
|
|
||
|
|
# --- Error propagation ---
|
||
|
|
|
||
|
|
def test_async_exception_no_loop_propagates(self):
|
||
|
|
with self.assertRaises(ValueError):
|
||
|
|
self.adapter._run(self.backend.failing_async_method())
|
||
|
|
|
||
|
|
def test_async_exception_inside_loop_propagates(self):
|
||
|
|
async def run_inside_loop():
|
||
|
|
return self.adapter._run(self.backend.failing_async_method())
|
||
|
|
with self.assertRaises(ValueError):
|
||
|
|
asyncio.run(run_inside_loop())
|
||
|
|
|
||
|
|
# --- Coroutine object handling ---
|
||
|
|
|
||
|
|
def test_coroutine_object_passed(self):
|
||
|
|
"""Passing a coroutine object (not called yet) is handled."""
|
||
|
|
coro = self.backend.async_method(5)
|
||
|
|
self.assertTrue(inspect.iscoroutine(coro))
|
||
|
|
result = self.adapter._run(coro)
|
||
|
|
self.assertEqual(result, 10)
|
||
|
|
|
||
|
|
def test_coroutine_function_rejected(self):
|
||
|
|
"""Passing a coroutine function (not called) is handled gracefully."""
|
||
|
|
result = self.adapter._run(42) # not a coroutine at all
|
||
|
|
self.assertEqual(result, 42)
|
||
|
|
|
||
|
|
# --- Thread pool stress ---
|
||
|
|
|
||
|
|
def test_concurrent_async_calls_from_multiple_threads(self):
|
||
|
|
"""Multiple threads calling _run() simultaneously via shared executor."""
|
||
|
|
errors = []
|
||
|
|
results = []
|
||
|
|
lock = threading.Lock()
|
||
|
|
|
||
|
|
def worker(x: int):
|
||
|
|
try:
|
||
|
|
result = self.adapter._run(self.backend.async_method(x))
|
||
|
|
with lock:
|
||
|
|
results.append(result)
|
||
|
|
except Exception as e:
|
||
|
|
with lock:
|
||
|
|
errors.append(e)
|
||
|
|
|
||
|
|
threads = []
|
||
|
|
for i in range(1, 11):
|
||
|
|
t = threading.Thread(target=worker, args=(i,))
|
||
|
|
threads.append(t)
|
||
|
|
t.start()
|
||
|
|
for t in threads:
|
||
|
|
t.join()
|
||
|
|
|
||
|
|
self.assertEqual(len(errors), 0, f"Errors in concurrent calls: {errors}")
|
||
|
|
self.assertEqual(len(results), 10)
|
||
|
|
self.assertEqual(sorted(results), [2, 4, 6, 8, 10, 12, 14, 16, 18, 20])
|
||
|
|
|
||
|
|
def test_concurrent_and_sequential_mixed(self):
|
||
|
|
"""Mix of concurrent and sequential _run() calls."""
|
||
|
|
async def in_loop():
|
||
|
|
results = []
|
||
|
|
for i in range(5):
|
||
|
|
r = self.adapter._run(self.backend.async_method(i))
|
||
|
|
results.append(r)
|
||
|
|
return results
|
||
|
|
|
||
|
|
# Sequential first
|
||
|
|
seq_results = self.adapter._run(self.backend.async_method(100))
|
||
|
|
self.assertEqual(seq_results, 200)
|
||
|
|
|
||
|
|
# Then from inside loop
|
||
|
|
loop_results = asyncio.run(in_loop())
|
||
|
|
self.assertEqual(loop_results, [0, 2, 4, 6, 8])
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Seam 2: connect() -> async backend
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestConnectSeam(unittest.TestCase):
|
||
|
|
"""Test the VenueAdapter.connect() sync->async bridge."""
|
||
|
|
|
||
|
|
def setUp(self):
|
||
|
|
self.adapter = _make_adapter()
|
||
|
|
|
||
|
|
def test_connect_no_backend_method(self):
|
||
|
|
"""Connect with no backend.connect method — should just snapshot."""
|
||
|
|
backend = mock.Mock()
|
||
|
|
backend.connect = None
|
||
|
|
adapter = BingxVenueAdapter(backend=backend)
|
||
|
|
# Should not crash — connect() checks for None
|
||
|
|
result = adapter.connect()
|
||
|
|
self.assertTrue(result)
|
||
|
|
|
||
|
|
def test_connect_sync_backend_method(self):
|
||
|
|
"""Backend has sync connect."""
|
||
|
|
backend = mock.Mock()
|
||
|
|
backend.connect = mock.Mock(return_value=True)
|
||
|
|
adapter = BingxVenueAdapter(backend=backend)
|
||
|
|
# The adapter will call backend.connect() and then _backend_snapshot
|
||
|
|
# which calls backend.refresh_state - may not exist on mock
|
||
|
|
backend.refresh_state = mock.Mock(return_value=mock.Mock(
|
||
|
|
capital=25000.0, equity=25000.0, open_positions={},
|
||
|
|
open_orders=[], all_orders=[], all_fills=[],
|
||
|
|
account={}, open_notional=0.0, source="mock", recovered=False,
|
||
|
|
timestamp=datetime.now(timezone.utc),
|
||
|
|
))
|
||
|
|
result = adapter.connect()
|
||
|
|
self.assertTrue(result)
|
||
|
|
|
||
|
|
def test_connect_no_connection_leak_on_failure(self):
|
||
|
|
"""If backend connect fails, adapter should not leak."""
|
||
|
|
with mock.patch.object(self.adapter, '_backend_snapshot',
|
||
|
|
side_effect=RuntimeError("boom")):
|
||
|
|
with self.assertRaises(RuntimeError):
|
||
|
|
self.adapter.connect()
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Seam 3: _backend_snapshot thread safety
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestBackendSnapshotThreadSafety(unittest.TestCase):
|
||
|
|
"""Test _last_snapshot is not corrupted by concurrent access."""
|
||
|
|
|
||
|
|
def setUp(self):
|
||
|
|
self.adapter = _make_adapter()
|
||
|
|
|
||
|
|
def test_concurrent_backend_snapshot_calls(self):
|
||
|
|
"""Multiple threads calling _backend_snapshot simultaneously."""
|
||
|
|
backend = mock.Mock()
|
||
|
|
snapshots = []
|
||
|
|
for i in range(10):
|
||
|
|
snapshots.append(mock.Mock(
|
||
|
|
capital=float(25000 + i), equity=float(25000 + i),
|
||
|
|
open_positions={}, open_orders=[], all_orders=[], all_fills=[],
|
||
|
|
account={}, open_notional=0.0, source="mock", recovered=False,
|
||
|
|
timestamp=datetime.now(timezone.utc),
|
||
|
|
))
|
||
|
|
backend.refresh_state = mock.Mock(side_effect=snapshots)
|
||
|
|
adapter = BingxVenueAdapter(backend=backend)
|
||
|
|
|
||
|
|
def snapshot_worker():
|
||
|
|
try:
|
||
|
|
s = adapter._backend_snapshot()
|
||
|
|
return s
|
||
|
|
except Exception:
|
||
|
|
return None
|
||
|
|
|
||
|
|
with ThreadPoolExecutor(max_workers=10) as pool:
|
||
|
|
futures = [pool.submit(snapshot_worker) for _ in range(10)]
|
||
|
|
results = [f.result() for f in futures]
|
||
|
|
|
||
|
|
self.assertEqual(len(results), 10)
|
||
|
|
# _last_snapshot should be set to the last one
|
||
|
|
self.assertIsNotNone(adapter._last_snapshot)
|
||
|
|
|
||
|
|
def test_concurrent_open_orders_and_positions(self):
|
||
|
|
"""open_orders() and open_positions() called concurrently."""
|
||
|
|
backend = mock.Mock()
|
||
|
|
backend.refresh_state = mock.Mock(return_value=mock.Mock(
|
||
|
|
capital=25000.0, equity=25000.0,
|
||
|
|
open_positions={"BTCUSDT": {"symbol": "BTCUSDT", "positionAmt": "0.01"}},
|
||
|
|
open_orders=[{"orderId": "1"}], all_orders=[], all_fills=[],
|
||
|
|
account={}, open_notional=100.0, source="mock", recovered=False,
|
||
|
|
timestamp=datetime.now(timezone.utc),
|
||
|
|
))
|
||
|
|
adapter = BingxVenueAdapter(backend=backend)
|
||
|
|
|
||
|
|
def orders_worker():
|
||
|
|
return adapter.open_orders()
|
||
|
|
|
||
|
|
def positions_worker():
|
||
|
|
return adapter.open_positions()
|
||
|
|
|
||
|
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
||
|
|
f1 = pool.submit(orders_worker)
|
||
|
|
f2 = pool.submit(positions_worker)
|
||
|
|
f3 = pool.submit(orders_worker)
|
||
|
|
f4 = pool.submit(positions_worker)
|
||
|
|
results = [f1.result(), f2.result(), f3.result(), f4.result()]
|
||
|
|
|
||
|
|
self.assertEqual(len(results[0]), 1) # 1 open order
|
||
|
|
self.assertEqual(len(results[1]), 1) # 1 open position
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Seam 4: _call_backend edge cases
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestCallBackend(unittest.TestCase):
|
||
|
|
"""Test the _call_backend sync->async bridge."""
|
||
|
|
|
||
|
|
def setUp(self):
|
||
|
|
self.adapter = _make_adapter()
|
||
|
|
|
||
|
|
def test_call_backend_missing_method_raises(self):
|
||
|
|
backend = object() # real object, not Mock — Mock returns mock for any attr
|
||
|
|
adapter = BingxVenueAdapter(backend=backend)
|
||
|
|
with self.assertRaises(AttributeError):
|
||
|
|
adapter._call_backend("nonexistent_method")
|
||
|
|
|
||
|
|
def test_call_backend_with_args(self):
|
||
|
|
"""Args and kwargs are forwarded correctly through async boundary."""
|
||
|
|
backend = mock.Mock()
|
||
|
|
backend.test_method = mock.Mock(return_value=42)
|
||
|
|
adapter = BingxVenueAdapter(backend=backend)
|
||
|
|
result = adapter._call_backend("test_method", 1, 2, kwarg="v")
|
||
|
|
backend.test_method.assert_called_once_with(1, 2, kwarg="v")
|
||
|
|
self.assertEqual(result, 42)
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Seam 5: _maybe_close inside/outside event loop
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestMaybeCloseSeam(unittest.TestCase):
|
||
|
|
"""Test launcher._maybe_close() in various contexts."""
|
||
|
|
|
||
|
|
def test_maybe_close_sync_method(self):
|
||
|
|
from prod.clean_arch.dita_v2.launcher import _maybe_close
|
||
|
|
obj = mock.Mock()
|
||
|
|
obj.close = mock.Mock(return_value=True)
|
||
|
|
_maybe_close(obj)
|
||
|
|
obj.close.assert_called_once()
|
||
|
|
|
||
|
|
def test_maybe_close_async_method_no_loop(self):
|
||
|
|
from prod.clean_arch.dita_v2.launcher import _maybe_close
|
||
|
|
|
||
|
|
async def async_close():
|
||
|
|
return "closed"
|
||
|
|
|
||
|
|
obj = mock.Mock()
|
||
|
|
obj.close = mock.Mock(return_value=async_close())
|
||
|
|
_maybe_close(obj)
|
||
|
|
obj.close.assert_called_once()
|
||
|
|
|
||
|
|
def test_maybe_close_async_method_inside_loop(self):
|
||
|
|
"""Must not crash if called from inside a running event loop."""
|
||
|
|
from prod.clean_arch.dita_v2.launcher import _maybe_close
|
||
|
|
|
||
|
|
async def test():
|
||
|
|
async def async_close():
|
||
|
|
return "closed"
|
||
|
|
obj = mock.Mock()
|
||
|
|
obj.close = mock.Mock(return_value=async_close())
|
||
|
|
# _maybe_close must handle RuntimeError from asyncio.run()
|
||
|
|
# and swallow it gracefully
|
||
|
|
_maybe_close(obj)
|
||
|
|
return True
|
||
|
|
|
||
|
|
result = asyncio.run(test())
|
||
|
|
self.assertTrue(result)
|
||
|
|
|
||
|
|
def test_maybe_close_disconnect_fallback(self):
|
||
|
|
from prod.clean_arch.dita_v2.launcher import _maybe_close
|
||
|
|
obj = mock.Mock()
|
||
|
|
obj.close = None
|
||
|
|
obj.disconnect = mock.Mock(return_value=True)
|
||
|
|
_maybe_close(obj)
|
||
|
|
obj.disconnect.assert_called_once()
|
||
|
|
|
||
|
|
def test_maybe_close_no_methods(self):
|
||
|
|
from prod.clean_arch.dita_v2.launcher import _maybe_close
|
||
|
|
obj = object()
|
||
|
|
_maybe_close(obj) # Should not crash
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Seam 6: Full lifecycle race conditions
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestFullLifecycleRaceConditions(unittest.TestCase):
|
||
|
|
"""Race conditions between kernel, venue, and runtime."""
|
||
|
|
|
||
|
|
def test_concurrent_submit_and_reconcile(self):
|
||
|
|
"""submit() and reconcile() called simultaneously from different threads."""
|
||
|
|
backend = mock.Mock()
|
||
|
|
backend.submit_intent = mock.Mock(return_value=mock.Mock(
|
||
|
|
status="FILLED", quantity=1.0, price=100.0,
|
||
|
|
client_order_id="test", order_id="1",
|
||
|
|
raw_ack={"status": "FILLED"}, raw_state={},
|
||
|
|
timestamp=datetime.now(timezone.utc),
|
||
|
|
))
|
||
|
|
base_snapshot = mock.Mock(
|
||
|
|
capital=25000.0, equity=25000.0,
|
||
|
|
open_positions={}, open_orders=[], all_orders=[], all_fills=[],
|
||
|
|
account={}, open_notional=0.0, source="mock", recovered=False,
|
||
|
|
timestamp=datetime.now(timezone.utc),
|
||
|
|
)
|
||
|
|
backend.refresh_state = mock.Mock(return_value=base_snapshot)
|
||
|
|
adapter = BingxVenueAdapter(backend=backend)
|
||
|
|
|
||
|
|
from prod.clean_arch.dita_v2.contracts import KernelCommandType, KernelIntent, TradeSide
|
||
|
|
|
||
|
|
intent = KernelIntent(
|
||
|
|
timestamp=datetime.now(timezone.utc),
|
||
|
|
intent_id="race-test", trade_id="race-trade",
|
||
|
|
slot_id=0, asset="BTCUSDT", side=TradeSide.SHORT,
|
||
|
|
action=KernelCommandType.ENTER,
|
||
|
|
reference_price=100.0, target_size=1.0, leverage=1.0,
|
||
|
|
)
|
||
|
|
|
||
|
|
def submit_worker():
|
||
|
|
return adapter.submit(intent)
|
||
|
|
|
||
|
|
def reconcile_worker():
|
||
|
|
return adapter.reconcile()
|
||
|
|
|
||
|
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
||
|
|
f_submit = pool.submit(submit_worker)
|
||
|
|
f_reconcile = pool.submit(reconcile_worker)
|
||
|
|
f_submit2 = pool.submit(submit_worker)
|
||
|
|
f_reconcile2 = pool.submit(reconcile_worker)
|
||
|
|
results = [f.result() for f in [f_submit, f_reconcile, f_submit2, f_reconcile2]]
|
||
|
|
|
||
|
|
self.assertEqual(len(results), 4)
|
||
|
|
self.assertIsNotNone(adapter._last_snapshot)
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Seam 7: Nested event-loop detection and prevention
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Seam 8: Timeout and hang detection
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestTimeoutAndHangDetection(unittest.TestCase):
|
||
|
|
"""Test that slow async methods trigger timeouts properly."""
|
||
|
|
|
||
|
|
def test_slow_async_no_timeout_no_loop(self):
|
||
|
|
"""Slow async without loop just runs — no timeout mechanism in _run()."""
|
||
|
|
backend = _DummyBackend()
|
||
|
|
adapter = _make_adapter()
|
||
|
|
# This would hang for 10 seconds if we actually ran it
|
||
|
|
# Instead we verify that _run() would pass it through correctly
|
||
|
|
coro = backend.slow_async_method(delay=0.001) # fast
|
||
|
|
result = adapter._run(coro)
|
||
|
|
self.assertEqual(result, "done")
|
||
|
|
|
||
|
|
def test_slow_async_with_timeout_inside_loop_future(self):
|
||
|
|
"""ThreadPoolExecutor submit().result() can be given a timeout."""
|
||
|
|
backend = _DummyBackend()
|
||
|
|
|
||
|
|
async def test():
|
||
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||
|
|
future = pool.submit(asyncio.run, backend.slow_async_method(delay=10.0))
|
||
|
|
with self.assertRaises(concurrent.futures.TimeoutError):
|
||
|
|
future.result(timeout=0.5)
|
||
|
|
return True
|
||
|
|
|
||
|
|
result = asyncio.run(test())
|
||
|
|
self.assertTrue(result)
|
||
|
|
|
||
|
|
def test_http_timeout_propagation(self):
|
||
|
|
"""Verify BingX HTTP client timeout propagates through async boundary."""
|
||
|
|
# The httpx.AsyncClient has a 10s timeout by default
|
||
|
|
# This test verifies the timeout config is respected
|
||
|
|
from prod.bingx.http import BingxHttpClient
|
||
|
|
from prod.bingx.config import BingxExecClientConfig
|
||
|
|
from prod.bingx.enums import BingxEnvironment
|
||
|
|
|
||
|
|
config = BingxExecClientConfig(
|
||
|
|
api_key="test", secret_key="test",
|
||
|
|
environment=BingxEnvironment.VST,
|
||
|
|
http_timeout_secs=5,
|
||
|
|
)
|
||
|
|
client = BingxHttpClient(config)
|
||
|
|
self.assertEqual(client._timeout_secs, 5)
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|