"""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()