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).