Skip to content

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.