PINK: cancel_async, S2 task guard, 29 new regression tests — 346/346 green
Bug fixes:
1. bingx_venue.py: add cancel_async() — async cancel that awaits backend.cancel()
directly in the main event loop. The sync cancel() path goes through _run()
→ thread-pool → asyncio.run() in a new thread, but aiohttp is bound to the
main loop → deadlock. Identical root cause as the old sync submit() → fixed
via submit_async. Remove dead cancel_order branch (BingxDirectExecutionAdapter
has cancel, not cancel_order).
2. rust_backend.py: process_intent_async CANCEL path now uses cancel_async when
available (matching the submit_async pattern for ENTER/EXIT). Sync cancel()
fallback kept for MockVenueAdapter compat.
3. bingx_direct.py: guard S2 background refresh task per symbol. Old code discarded
the task reference; rapid submits piled up concurrent _refresh_state_background
calls all writing self._state in arbitrary completion order (stale last-writer-
wins). Now: skip creating a new task if one is already pending for the symbol;
store reference and clear via done-callback.
Test additions (test_bingx_bugs.py, 29 tests):
- cancel_async: awaitable, calls backend.cancel directly, maps all statuses
- process_intent_async CANCEL: dispatches cancel_async / falls back to sync
- S2 guard: task stored, no duplicates while pending, new task after done
- _events_from_submit with None snapshots: FILLED/NEW/REJECTED/PARTIAL/RATE_LIMITED
- _filled_size_from_snapshots(None, None): safe 0.0 return
- _events_from_cancel: before/after completely ignored
- connect(): no double refresh_state, no-op if backend has no connect
- submit() sync with None snapshots: FULL_FILL still emitted
- cancel() branch audit: uses cancel not cancel_order, raises for no-cancel backend
Fix: test_exchange_event_seam_parity.py TestMockSubscribe — replace deprecated
asyncio.get_event_loop().run_until_complete() with asyncio.run() (Python 3.12
raises RuntimeError when event loop is closed by earlier suite tests).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -226,6 +226,11 @@ class BingxDirectExecutionAdapter(ExecutionPort):
|
||||
|
||||
# ── S2: Background state refresh tracking ─────────────────────────────
|
||||
self._state_refreshed_at: float = 0.0 # monotonic seconds
|
||||
# Per-symbol pending background-refresh task. Prevents concurrent REST
|
||||
# calls piling up (rapid submits) — if a task is already running for a
|
||||
# symbol we skip creating a new one; the running task captures state after
|
||||
# the most recent fill anyway. Done callback removes the entry on completion.
|
||||
self._s2_tasks: Dict[str, "asyncio.Task[None]"] = {}
|
||||
|
||||
@property
|
||||
def state(self) -> ExchangeStateSnapshot | None:
|
||||
@@ -706,10 +711,14 @@ class BingxDirectExecutionAdapter(ExecutionPort):
|
||||
# For LIMIT / non-FILLED orders: must refresh synchronously to detect resting order.
|
||||
market_filled = (status == "FILLED" and not is_limit)
|
||||
if market_filled:
|
||||
asyncio.create_task(
|
||||
self._refresh_state_background(intent.asset),
|
||||
name=f"state_refresh_{symbol}",
|
||||
)
|
||||
existing = self._s2_tasks.get(symbol)
|
||||
if existing is None or existing.done():
|
||||
task = asyncio.create_task(
|
||||
self._refresh_state_background(intent.asset),
|
||||
name=f"state_refresh_{symbol}",
|
||||
)
|
||||
self._s2_tasks[symbol] = task
|
||||
task.add_done_callback(lambda _t, _s=symbol: self._s2_tasks.pop(_s, None))
|
||||
else:
|
||||
self._state = await self._refresh_exchange_state(intent.asset, include_history=False)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user