PINK: fix event-loop corruption in open_positions/open_orders + 23-test orphan suite
Root cause: open_positions()/open_orders() called _backend_snapshot() -> _call_backend() -> _run() -> pool.submit(asyncio.run, coro) which spawned a temporary event loop in a worker thread. httpx AsyncClient created inside that temp loop, loop closed immediately. All subsequent HTTP calls raised Event loop is closed or asyncio.locks.Event bound to different loop. Crash triggered WS stream reconnects; each reconnect re-ran reconcile with N>1 BingX positions and orphaned all but the largest. Fix: open_positions()/open_orders() now read backend._state (populated by await backend.connect() in the main loop). Fallback to _backend_snapshot() for callers without a connected backend. Fixes test_bingx_bugs::TestConnectNoDoubleRefresh: connect() is now async. New test_orphan_prevention.py: 23 tests covering all 5 orphan mechanisms: A. open_positions/open_orders use backend._state, never hit thread pool B. connect() awaitable, backend.connect() runs in main event loop C. Reconcile guard: >1 position logs ERROR and takes only largest D. clientOrderId p-action-base36-rand4 on every order E. EXIT sizing capped to kernel slot_size 391 passed, 2 skipped, 0 failed across all 14 test files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -341,10 +341,21 @@ class BingxVenueAdapter(VenueAdapter):
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def connect(self) -> bool:
|
||||
result = getattr(self.backend, "connect", None)
|
||||
if result is not None:
|
||||
self._run(result())
|
||||
async def connect(self) -> bool:
|
||||
"""Async connect — awaits backend.connect() in the caller's event loop.
|
||||
|
||||
The old sync path called self._run(backend.connect()) which spawns a
|
||||
thread-pool asyncio.run(), creating the httpx AsyncClient in a temporary
|
||||
loop that immediately closes. Every subsequent request then raises
|
||||
"Event loop is closed" or "bound to a different event loop".
|
||||
Awaiting directly here fixes that: the client is always created in the
|
||||
main running loop.
|
||||
"""
|
||||
conn_fn = getattr(self.backend, "connect", None)
|
||||
if conn_fn is not None:
|
||||
result = conn_fn()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
# backend.connect() already called refresh_state() — no second fetch needed
|
||||
return True
|
||||
|
||||
@@ -402,10 +413,23 @@ class BingxVenueAdapter(VenueAdapter):
|
||||
return self._events_from_cancel(order, response, None, None, reason=reason)
|
||||
|
||||
def open_orders(self) -> List[VenueOrder]:
|
||||
# Use backend._state (populated by await backend.connect()) rather than
|
||||
# _backend_snapshot() → _call_backend() → _run() → pool.submit(asyncio.run)
|
||||
# which spawns a temporary event loop, creates the httpx AsyncClient inside
|
||||
# it, then closes that loop — every subsequent HTTP call then raises
|
||||
# "Event loop is closed" or "asyncio.locks.Event bound to a different loop".
|
||||
backend_state = getattr(self.backend, "_state", None)
|
||||
if backend_state is not None:
|
||||
return [_venue_order_from_row(row) for row in (backend_state.open_orders or [])]
|
||||
snapshot = self._backend_snapshot(include_history=False)
|
||||
return [_venue_order_from_row(row) for row in (snapshot.open_orders or [])]
|
||||
|
||||
def open_positions(self) -> List[dict[str, Any]]:
|
||||
# Same rationale as open_orders(): prefer cached backend._state to avoid
|
||||
# the thread-pool asyncio.run() path that corrupts the httpx session.
|
||||
backend_state = getattr(self.backend, "_state", None)
|
||||
if backend_state is not None:
|
||||
return [dict(row) for row in (backend_state.open_positions or {}).values()]
|
||||
snapshot = self._backend_snapshot(include_history=False)
|
||||
return [dict(row) for row in (snapshot.open_positions or {}).values()]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user