Third-party libraries under simulation¶
docs/supported-api.md states which asyncio APIs simloop simulates. This page answers the next question — what happens when a real library runs on top of them — with evidence rather than intent: every row below is the output of a script anyone can re-run.
Recorded 2026-08-04, against simloop 0.2.0 (unreleased) on Python 3.12 with OpenSSL 3.5.7.
What a probe is¶
A probe is a small script in probes/ that drives one library's happy path
under a SimLoop — between two simulated hosts where the library talks to a
network — and reports a single verdict:
works: <what was exercised>— the probe ran to the end of its own script and described what it did.fenced: <message>— aSimulationFenceErrorwas raised during the run. The message is reproduced exactly.fails: <exception>— the run ended some other way: an exception from the library, or the probe's virtual-time budget running out.
A verdict is a statement about that one run and nothing more. works means
those calls, on that version, produced that result; it is not a support
claim, and the same library may well fence one call later. The probes drive
happy paths only: no retries, no reconnection, no concurrency beyond what the
probe itself starts. TLS is a happy path they now drive, with certificates
minted in memory for the sim hostnames the probes use.
Regenerating the table¶
and paste the output below. The probe libraries are pinned exactly in the
probes dependency group in pyproject.toml, so the version column
describes what actually ran; uv run pytest does not install them, and
probes/ is never packaged. The probes are deliberately not part of CI — a
third-party release should not break simloop's build — which is why this page
carries a date instead.
| Library | Version | Verdict | Notes |
|---|---|---|---|
| aiohttp (server) | 3.14.3 | works: one GET over the sim network: 'HTTP/1.1 200 OK', body 'hello from the simulation' | AppRunner + loop.create_server on a sim host; raw sim-stream client. |
| aiohttp (web.TCPSite) | 3.14.3 | works: web.TCPSite(...).start() bound port 8081 on a sim host | The documented AppRunner + TCPSite startup path, nothing else. |
| anyio | 4.14.2 | works: task group, memory object stream (one, two, three), anyio.sleep and move_on_after; virtual clock reached 1.75s | Asyncio backend only; nothing here touches a socket. |
| redis (RESP wire protocol) | n/a | works: PING, SET and GET round trips over one connection: ['+PONG', '+OK', '0'] | Hand-rolled RESP over sim streams; no client library, no real server. |
| websockets | 17.0.1 | works: handshake, one echoed frame ('HELLO') and close over ws:// | asyncio server and client on two sim hosts, ws:// only. |
| websockets (wss) | 17.0.1 | works: handshake, one echoed frame ('HELLO') and close over wss:// | asyncio server and client on two sim hosts, over wss://. |
| aiohttp (client) | 3.14.3 | works: ClientSession GET returned 'hello from the simulation' | ClientSession GET at a sim host answered by a raw stream server. |
| aiohttp (client, https) | 3.14.3 | works: ClientSession GET over https returned 'hello from the simulation' | ClientSession GET over https at a sim host with a minted certificate. |
| httpx | 0.28.1 | works: AsyncClient GET returned 'hello from the simulation' | AsyncClient GET at a sim host answered by a raw stream server. |
| httpx (https) | 0.28.1 | works: AsyncClient GET over https returned 'hello from the simulation' | AsyncClient GET over https; the TLS engine is anyio's, not the loop's. |
Rows are grouped: the libraries that need nothing but the loop and its streams first, then the client stacks that expect a socket object underneath them.
Reading the rows¶
anyio needs nothing but tasks, futures and timers, so its asyncio backend
runs unchanged. That matters beyond anyio itself: it is the concurrency layer
under httpx, starlette and anything built on anyio.to_thread-free code.
aiohttp's server answers requests over the simulated network, both
through loop.create_server directly and through the documented
AppRunner + web.TCPSite startup path — the one web.run_app uses.
The two rows exist because they once differed: TCPSite reads
server.sockets during startup, which the simulated server did not answer
until it learned to report an empty tuple (there are no sockets in a
simulation, and the stdlib documents the tuple as possibly empty).
websockets completes a handshake, echoes a frame and closes cleanly
over ws:// between two sim hosts. Its serve() reads the same
server.sockets attribute during startup — only to log where it is
listening — so it was unblocked by the same empty tuple.
redis has no row for a client library: every async Redis client needs a live server to reach its first command, and a live server is exactly what a simulation does not have. What is testable without one is the pattern those clients are built on — a length-prefixed request/response protocol on one long-lived connection — so the probe speaks RESP by hand against a small server on a second sim host, and the row claims no more than that.
aiohttp's client issues its GET and reads the body back. Its connector
resolves the name through loop.getaddrinfo, then hands the addresses to
aiohappyeyeballs, which creates a real AF_INET stream socket, calls
loop.sock_connect on it, and passes that socket to
loop.create_connection(sock=...). The simulation answers the sequence
without letting the socket reach a network: sock_connect resolves the
target against the host table and records it, moving no packet and no
clock, and the create_connection call closes the real descriptor and
opens a simulated connection to the recorded address, paying the same
single round trip a direct create_connection would. The connector's
setsockopt(TCP_NODELAY) lands on the stand-in object
get_extra_info("socket") returns, which accepts option calls and does
nothing with them.
httpx completes the same request through httpcore and anyio. anyio
resolves the name (the simulated resolver accepts the ASCII-encoded form
anyio sends) and connects through loop.create_connection, with no socket
of its own. The step that used to end this row comes next: httpcore asks
the new stream who it is connected to, and anyio answers by reading the
socket object out of transport.get_extra_info("socket")
(anyio/abc/_sockets.py, extra_attributes) and calling getpeername()
on it. A transport with no operating-system socket now answers with a
stand-in that reports the peer's synthetic address and port, so the
introspection succeeds and the response body comes back.
Every client probe makes one request against a responder that sends
Connection: close, so no row says anything about connection reuse.
The piece a pool depends on is the descriptor fileno() returns: httpcore
polls it to decide whether a pooled connection has died, and the
simulation backs it with a parked descriptor the transport owns, which
stays unreadable while the peer is alive and becomes readable once the
peer's EOF arrives; a reset or a teardown closes it and fileno() returns
-1, which the same poll reads as dead just as well. That contract is
pinned by the test suite, not by these rows.
aiohttp over https:// completes the same two-call connect its
http:// path uses, with ssl and server_hostname riding alongside
sock in the create_connection call. What the connector does after the
connect is answered by the two layers together: sslcontext and
ssl_object come from the TLS layer, peername and the setsockopt on
the stand-in socket from the simulated transport underneath it. The
certificate is minted for the sim hostname web and the client context
trusts that authority and nothing else, so the row says OpenSSL really
verified rather than that verification was turned off.
httpx over https:// reaches no loop TLS API at all, which is why it
is worth its own row. httpcore wraps the byte stream with anyio's
TLSStream, which drives an ssl memory BIO inside the process and sends
the handshake as ordinary bytes over the simulated connection. Nothing in
simloop is involved in that handshake; what changed is that the simulation
now has a peer on the other end that speaks TLS back, so the request
completes instead of dying of httpx's own ConnectTimeout.
websockets over wss:// completes a handshake, echoes a frame and
closes with both ends inside the simulation. It is the only row that
drives create_server(ssl=...) and create_connection(ssl=...) in one
run.
Not tested¶
- asyncpg: reaching its first fence needs a live PostgreSQL server to connect to, which no probe can provide; it is untested rather than fenced-or-not.
- The rest of TLS: the three TLS rows drive a server certificate, one cipher suite and TLS 1.3. Client certificates, a peer restricted to TLS 1.2, ALPN and h2 negotiation, and session resumption are not probed.
- Anything that reaches outside the loop by design — threads, subprocesses,
signals, real DNS. Those are fences, listed in
docs/supported-api.md, not compatibility questions.
Executors left this list:
run_in_executornow runs the function inline, which the same page describes;anyio.to_threadstays out because its worker threads are real ones the loop never sees.