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:
@@ -134,6 +134,36 @@ async def test_jackpot_comes_from_the_participants_actual_bets(client):
|
||||
assert body["jackpot_sats"] == (999_800_000 * 2) * 70 // 100
|
||||
|
||||
|
||||
async def test_draw_waiting_since_is_exposed_only_while_drawing(client):
|
||||
"""B-36: the "drawing" wait on a future block has no timeout, so the frontend
|
||||
needs draw_waiting_since to show "still waiting" instead of implying a bounded
|
||||
countdown. It must not leak for any other status, where it's meaningless."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.db.models import Round, RoundConfig
|
||||
|
||||
ac, session_factory = client
|
||||
|
||||
started_at = datetime(2026, 7, 27, 10, 0, 0)
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address=""))
|
||||
session.add(Round(id=60, status="drawing", drawing_started_at=started_at))
|
||||
await session.commit()
|
||||
|
||||
body = (await ac.get("/rounds/current")).json()
|
||||
assert body["draw_waiting_since"] == "2026-07-27T10:00:00+00:00"
|
||||
|
||||
async with session_factory() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
round_ = (await session.scalars(select(Round).where(Round.id == 60))).one()
|
||||
round_.status = "paying_out"
|
||||
await session.commit()
|
||||
|
||||
body = (await ac.get("/rounds/current")).json()
|
||||
assert body["draw_waiting_since"] is None
|
||||
|
||||
|
||||
async def test_unhandled_errors_use_the_structured_detail_shape(client):
|
||||
"""B-24: the catch-all handler answered with a bare-string `detail`, while
|
||||
app/api/errors.py documents detail as {"code", "message", "params"}. Clients then
|
||||
|
||||
@@ -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