Skip to content

blackbull.server.deadline

blackbull.server.deadline

Per-connection rescheduled deadline.

Replaces async with asyncio.timeout(...) on the per-request hot path, and the per-connection loop.call_later TimerHandle that had to be cancelled and rescheduled at every phase transition. Instead, a per-process tick scanner: one singleton TimerHandle re-arms itself every :data:_TICK_S and walks the registry of armed :class:ConnectionDeadline instances for expirations. Per-arm cost is ~0.34 µs rather than ~1.7 µs.

Trade-off: a fired deadline lands within [now, now + _TICK_S] rather than at the exact requested instant. At the default _TICK_S = 0.3 s this is ~3 % slop on the tightest configurable deadline (BB_HEADER_TIMEOUT default 10 s) and ~1 % on the body_timeout default 30 s. Tune via BB_DEADLINE_TICK_MS (milliseconds, default 300, floor 10).

The scanner is loop-scoped and lazily started on the first arm; it quiesces (cancels its own handle) when the registry empties and auto-resurrects on the next arm. Each worker process has its own scanner.

ConnectionDeadline

One reusable deadline per connection.

The instance binds to the task that constructed it (in practice, the connection actor's task). When the per-process scanner observes that the deadline's monotonic _deadline_at has passed, the bound task is cancelled — the cancellation propagates into whichever reader.readuntil / read / readexactly is currently awaiting. Call sites translate the cancellation into TimeoutError via :meth:guard (the common case) or manually by checking :attr:fired.

Why a scanner instead of one call_later per arm? At saturation the per-arm path costs ~1.7 µs (TimerHandle + heap push + cancel). The scanner replaces that with one loop.time() call + one comparison + one set membership check on the registry (~0.34 µs) — an ~80 % per-call reduction.

arm(seconds)

(Re-)set the deadline; seconds <= 0 disables it.

Safe to call repeatedly. Resets :attr:fired so a recovered deadline can be reused across phases on the same connection.

disarm()

Drop the deadline. Idempotent.

guard(seconds)

Arm the deadline and return self as a context manager.

Caller pattern::

with dl.guard(cfg.header_timeout):
    await reader.readuntil(...)

Matches the observable behaviour of async with asyncio.timeout(d): — a fired deadline manifests as TimeoutError. The same-loop-iteration race where the underlying read completes and the deadline fires in the same tick is treated as a timeout (same convention as asyncio.timeout).

Returns self rather than allocating a wrapper object. Safe because each connection owns its own ConnectionDeadline and uses it sequentially from a single task.

WriteDeadline

Bounds a drain on a connection's writer, via the same scanner.

Rides in :data:_Scanner._REGISTRY alongside :class:ConnectionDeadline — the scanner only needs _deadline_at and _fire_from_scanner. Two differences from that class, both forced by the write path:

Binding is per-arm, not per-construction. HTTP/2 drains the one connection-level writer from per-stream tasks, so the task to cancel is whichever one is parked in drain() right now — not whichever one happened to build the writer.

One owner at a time. Concurrent drains are not nested drains, so a second entrant does not re-arm and does not interpret a firing — it simply rides along. Two tasks can only be inside drain() simultaneously when the transport is paused, which is precisely the slow-read shape the timeout defends against; letting a later drain push the deadline out would let a peer that dribbles acknowledgements hold the connection open indefinitely. When the owner's deadline fires it closes the transport, which is what resolves the riders.

WsIdleWatchdog

Per-connection WebSocket idle state, riding the shared tick scanner.

Inline reading (the default) services PING/CLOSE only when the handler calls receive(). A handler that goes quiet — long work between reads, or send-only — stops servicing control frames, and (with a websocket_message listener) stops producing events. This watchdog bounds both: while a connection has been quiet for more than one tick, the scanner fires the connection's callback roughly every tick, and the callback services buffered control frames / starts the deferred reader.

The design constraint is the same one that built :class:ConnectionDeadline: no per-connection asyncio timers. One TimerHandle serves every connection; this object is just registry state plus a callback, re-armed on every fire so it keeps watching until :meth:disarm. touch() is called on each receive/send, which keeps an actively-driven connection from ever firing (the common case — so the default hot path pays a loop.time() + one comparison per message and no scanner work at all).

disarm()

Stop watching. Idempotent.

touch()

Mark connection activity; the watchdog goes quiet for _idle_s.