Make a stalled draw wait observable (B-36)
_wait_for_next_block had no timeout, no log, and no audit entry: a connection that stopped advancing the tip left a round silently frozen in "drawing" with nothing in /admin to explain why. Log progress periodically, write a draw_stalled audit entry past a threshold (a few block-time multiples), and surface the wait via a new Round.drawing_started_at column, exposed as draw_waiting_since in GET /rounds/current. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -381,7 +381,9 @@ async def test_wait_for_next_block_accepts_an_immediately_corroborated_block(ses
|
||||
listener = CorroboratingListener(responses={101: True})
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
|
||||
height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100)
|
||||
height, block_hash = await scheduler._wait_for_next_block(
|
||||
round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
assert height == 101
|
||||
assert listener.corroboration_calls == [101]
|
||||
@@ -395,7 +397,9 @@ async def test_wait_for_next_block_retries_after_a_failed_corroboration(session_
|
||||
)
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
|
||||
height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100)
|
||||
height, block_hash = await scheduler._wait_for_next_block(
|
||||
round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
assert height == 102
|
||||
assert listener.corroboration_calls == [101, 102]
|
||||
@@ -403,3 +407,53 @@ async def test_wait_for_next_block_retries_after_a_failed_corroboration(session_
|
||||
async with session_factory() as session:
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert events == ["draw_header_corroboration_failed"]
|
||||
|
||||
|
||||
# --- B-36: a stalled draw must be visible, not a silent frozen wait --------------
|
||||
|
||||
|
||||
class StallingListener:
|
||||
"""A tip that never advances until the test decides it should — used to drive
|
||||
_wait_for_next_block's stall-detection past _DRAW_STALL_THRESHOLD_SECONDS
|
||||
without a real 6-minute wait."""
|
||||
|
||||
def __init__(self):
|
||||
self.tip_height = 100
|
||||
self.tip_header_hex = None
|
||||
|
||||
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def test_wait_for_next_block_logs_a_stall_audit_entry_past_the_threshold(session_factory, monkeypatch):
|
||||
import app.rounds.scheduler as scheduler_module
|
||||
|
||||
listener = StallingListener()
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
start = datetime.now(timezone.utc)
|
||||
|
||||
class _FakeClock:
|
||||
now = start
|
||||
|
||||
def fake_now(tz=None):
|
||||
return _FakeClock.now
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
_FakeClock.now += timedelta(seconds=seconds)
|
||||
# Past the stall threshold, but before it would repeat: unblock the wait
|
||||
# by making a (corroborated) block appear, so the test terminates.
|
||||
if _FakeClock.now >= start + timedelta(seconds=scheduler_module._DRAW_STALL_THRESHOLD_SECONDS + 30):
|
||||
listener.tip_height = 101
|
||||
listener.tip_header_hex = "aa"
|
||||
|
||||
monkeypatch.setattr(scheduler_module, "datetime", type("_D", (), {"now": staticmethod(fake_now)}))
|
||||
monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep)
|
||||
|
||||
height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100, waiting_since=start)
|
||||
|
||||
assert height == 101
|
||||
|
||||
async with session_factory() as session:
|
||||
entries = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].round_id == 1
|
||||
|
||||
Reference in New Issue
Block a user