Transports & lifecycle¶
This page covers the two remaining pieces of the story: how a transport moves
bytes between a socket and your Protocol, and how a loop lives from birth to
death.
Transports¶
A transport is the
object that sits between a socket and your Protocol. It moves bytes: it reads
from the socket and calls protocol.data_received(...), and it takes what you
write() and sends it down the socket.
In zloop, the transport's byte-moving runs in Zig (transport_obj.zig),
while it speaks to your Python Protocol through the CPython adapter.
The connection, end to end¶
When a server accepts a connection (or a client connects), here's the dance:
sequenceDiagram
participant K as Reactor
participant T as Transport
participant P as Protocol
Note over T,P: connection established
T->>P: connection_made
K-->>T: fd readable
T->>T: recv into buffer
T->>P: data_received
P->>T: transport.write
T->>T: send now, buffer the rest
Note over T,K: if not all sent, watch for writable
K-->>T: fd writable
T->>T: flush buffered bytes
K-->>T: fd hangup or EOF
T->>P: connection_lost
Reading¶
When the reactor says a connection's fd is readable, the transport's native read
callback fires (no Python round-trip just to learn "data arrived"). It recvs
into a stack buffer and hands the bytes to your protocol's data_received.
If the protocol is a buffered protocol (get_buffer / buffer_updated - the
zero-copy style that asyncio.sslproto uses for TLS), the transport reads
directly into the protocol's buffer instead. Both styles are supported.
A clean EOF calls eof_received(); if the protocol doesn't want to keep the
connection half-open, the transport closes.
Writing, and flow control¶
transport.write(data) tries to send immediately. If the kernel can't take it
all (a slow client, a full socket buffer), the rest is buffered and the
transport registers interest in "writable" - so it flushes the moment the socket
drains.
This is where flow control comes in. The write buffer has a high and a low watermark:
graph LR
A[buffer grows past<br/><b>high watermark</b>] -->|"pause_writing()"| B[protocol stops producing]
B --> C[buffer drains below<br/><b>low watermark</b>]
C -->|"resume_writing()"| D[protocol resumes]
When the buffer gets too big, the transport calls protocol.pause_writing(); when
it drains, protocol.resume_writing(). This is how a fast producer doesn't blow
up memory writing to a slow consumer - and it's exactly asyncio's contract, so
uvicorn's flow control "just works".
There's matching flow control on the read side: pause_reading() removes the fd
from the reactor (so no more data_received), and resume_reading() puts it
back.
The full transport interface¶
The transport implements everything asyncio (and uvicorn) expects:
write · writelines · close · abort · is_closing · write_eof ·
can_write_eof · pause_reading · resume_reading · set_protocol ·
get_protocol · get_extra_info · get_write_buffer_size ·
set_write_buffer_limits · get_write_buffer_limits
get_extra_info answers the keys uvicorn asks for. The raw socket transport
provides socket, sockname, peername, and sslcontext (for a TLS server).
On a TLS connection the app-facing transport is asyncio.sslproto's, which adds
the usual ssl_object / peercert after the handshake - so logging and TLS
introspection work.
Two correctness details worth knowing¶
connection_lost is deferred
asyncio never calls connection_lost synchronously from inside a close()
or a data_received - it schedules it for the next loop iteration. zloop does
the same. This matters more than it sounds: the WebSocket upgrade handshake in
uvicorn calls set_protocol() after the HTTP side has already started
closing. If connection_lost fired synchronously, it'd go to the wrong
protocol. Deferring it makes the handoff land correctly.
the loop keeps transports alive
An accepted connection has to stay alive even if your protocol doesn't store
the transport anywhere. asyncio keeps transports referenced until
connection_lost; zloop holds active transports in a set on the loop and
releases them on disconnect. Without this, a protocol that forgets to stash
self.transport would have its connection collected mid-flight.
These are the kinds of edge cases that don't show up in a quick demo but absolutely show up in a real server - so they're tested. 🙂
Loop lifecycle¶
Now let's trace a loop from birth to death, so the pieces from the previous pages connect into one story.
States¶
stateDiagram-v2
[*] --> Open: new_event_loop()
Open --> Running: run_until_complete()<br/>/ run_forever()
Running --> Open: stop() / future done
Open --> Closed: close()
Closed --> [*]: garbage collected
note right of Running
run_once() over and over
end note
note right of Closed
pending callbacks dropped;
no new work accepted
end note
A loop is Open when created, becomes Running while it's driving
callbacks, returns to Open when stopped, and is Closed for good once you
close() it.
run_until_complete, step by step¶
This is the method asyncio.run() ultimately calls. zloop implements it exactly
the way asyncio does:
sequenceDiagram
participant U as Your code
participant L as Loop
participant F as Task
participant E as Engine
U->>L: run_until_complete coro
L->>F: ensure_future wraps it in a Task
L->>F: add_done_callback that calls stop
L->>E: run_forever
loop until stopping
E->>E: run_once
E-->>F: steps the coroutine
end
F-->>L: done, callback calls stop
E-->>L: run_forever returns
L->>F: return future.result
L-->>U: result or raised exception
The trick is the done-callback: when the wrapped task finishes, it calls
loop.stop(), which breaks the run_forever loop. Then run_until_complete
returns the task's result - or re-raises its exception.
If the loop is stopped before the future completes, zloop raises a clear
RuntimeError("Event loop stopped before Future completed."), matching asyncio
rather than leaking an obscure internal error.
Shutdown and cleanup¶
asyncio.run() does a tidy shutdown sequence, and zloop participates in all of
it:
- cancel any remaining tasks and let them finish cancelling,
await loop.shutdown_asyncgens()- a no-op today: zloop doesn't install asyncgen finalizer hooks, so this is a compatibility placeholder (the method exists and is safe to call),await loop.shutdown_default_executor()- drain the thread pool,loop.close().
That last close() does something important for memory: it drops the engine's
pending callbacks and timers. This breaks a reference cycle - the engine holds
pending callback handles, and each handle holds a reference back to the loop - so
that once you're done, the loop can be garbage-collected cleanly.
Always close your loop
If you create a loop by hand, close() it when you're done (a try/finally
is the idiom). asyncio.run() and asyncio.Runner do this for you - which is
why they're the recommended way to run things. A loop that's abandoned
without being closed, while callbacks are still pending, can't break that
cycle and will leak.
After close¶
A closed loop is inert and says so. Calling call_soon, call_later,
add_reader, or create_server on a closed loop raises
RuntimeError("Event loop is closed") - exactly like asyncio. No silent enqueuing
onto a loop that will never run again.
import asyncio
import zloop
loop = zloop.new_event_loop()
loop.close()
loop.call_soon(print, "nope")
#> RuntimeError: Event loop is closed
And that's the whole life of a zloop loop. 🙂