blackbull.server.read_buffer¶
blackbull.server.read_buffer
¶
The single owned buffer for the H/1.1 inbound path.
One bytearray per connection, written directly by the kernel through
:meth:ReadBuffer.get_buffer and read by cursor. Every inbound byte is
materialised once: the head is sliced out for the parser, the body is handed
out as a memoryview, and a keep-alive peer's next request is simply the bytes
that were already sitting between the cursors.
This replaces reading through asyncio.StreamReader. The distinction that
matters is ownership, not buffering — a buffer layered over a reader that is
already buffering is a third copy, which measured slower than the per-line
readuntil loop it was meant to beat. The buffer only pays when it is the
only one.
Deliberately free of HTTP semantics. It reports an over-budget head with
:data:LIMIT_EXCEEDED rather than raising, because the 431 belongs to the
actor; it distinguishes "EOF with nothing" from "EOF mid-head" only by leaving
:attr:available intact, because deciding between a silent close and a 400 is
also the actor's job.
Not thread-safe and not concurrency-safe — one connection, one buffer, one actor loop, per the actor model.
ReadBuffer
¶
A cursor-addressed byte buffer fed by asyncio.BufferedProtocol.
available
property
¶
Unconsumed bytes currently resident.
capacity
property
¶
Size of the underlying allocation (diagnostics and tests).
examined_bytes
property
¶
Cumulative bytes the head scan has looked at on this connection.
Exposed so the linear-scan invariant is assertable rather than merely intended: a scan that restarted from the front on every arrival would make this quadratic in the number of segments, which is a peer-chosen CPU cost. Overlap of up to three bytes per resumption is expected — that is the straddled-terminator back-off.
buffer_updated(nbytes)
¶
Declare how much of the last :meth:get_buffer was written.
compact()
¶
Move the unconsumed tail to the front.
Called on message boundaries. Without it the cursors walk forward for the life of a keep-alive connection and the allocation grows to every byte ever received on it.
consume(n)
¶
Advance past n bytes handed out by :meth:view.
find(sep)
¶
Offset of sep within the resident bytes, or -1.
Unlike :meth:find_head_end this scans from the read cursor every
call: its callers are the generic readuntil paths (chunk-size lines,
WebSocket framing), where the search target changes between calls so a
carried scan offset would be wrong rather than merely wasteful.
find_head_end(limit=0)
¶
Length of the message head, terminator included, or a sentinel.
Returns -1 when the terminator has not arrived yet and
:data:LIMIT_EXCEEDED when limit (0 = unbounded) is passed without
one.
The scan resumes from where the last call stopped, backed off by three
bytes so a \r\n\r\n split across two arrivals is still found.
Without that resumption a peer dribbling one byte per segment makes
every arrival re-scan the whole head — quadratic, and attacker-chosen.
get_buffer(sizehint)
¶
Space for the transport to read into.
asyncio passes -1 when it has no preference, and the protocol
contract requires a non-empty buffer — returning an empty one stalls
the connection permanently.
The sizehint is what the transport would like to read in one
recv, not what it needs: uvloop's cleartext path passes libuv's fixed
64 KiB on every call, so honouring it would grow every connection's
buffer to 64 KiB on its first request and compact()/_release()
would give it back at the message boundary — a 64 KiB alloc/free
churn per request (the F5 read-path finding). Growth is driven by
bytes actually arriving (the _w cursor), never by the hint: offer
the buffer's free span, growing only when it falls below the read
floor.
take(n)
¶
Materialise and consume the next n bytes.
The one copy per message: the head goes to the parser as bytes
because the parse path's split/translate bulk ops need a real
buffer object, and those are what keep the parser at C speed.
Two ways to make that copy, and which one wins depends on size — so
the size decides, the same shape as the send path's join-vs-vectored
gate. Slicing the bytearray allocates an intermediate and copies
twice; a memoryview slice copies once but pays for building and
releasing the view. Measured on this tree (min of 7, µs/call):
| n | bytearray slice | memoryview | ratio |
|---|---|---|---|
| 300 | 0.102 | 0.180 | 1.77× |
| 4 KiB | 0.197 | 0.242 | 1.23× |
| 8 KiB | 0.270 | 0.289 | 1.07× |
| 16 KiB | 0.559 | 0.413 | 0.74× |
| 1 MiB | 1052 | 16.4 | 0.02× |
The crossover sits between 8 and 16 KiB. Below it the view setup dominates and the double copy is cheaper — and every request takes its head through here, so that is the hot path. Above it the second copy dominates and doubles peak memory besides (2.0 → 1.0 MiB on a 1 MiB take), which matters because a body read asks for whatever the peer declared.
Above the threshold the view is released explicitly rather than left
to refcounting: a bytearray with a live export raises BufferError
on resize, and the next get_buffer may resize. Tying that to when a
temporary happens to be collected is how it becomes a load-dependent
crash.
view(n)
¶
A view of the next n resident bytes — no copy, not consumed.
Body bytes reach the application through this, so a request that is
streamed or sent to a file never allocates a bytes for its payload.