fastepoll 2
A multi-process epoll server core for Python on Linux, with an asyncio-compatible protocol API. It serves TCP, Unix stream sockets and UDP from one C++ event loop per worker: a process, or a thread on free-threaded Python. A single abi3 wheel runs on CPython 3.12 and newer.
For HTTP/1.1 and HTTP/2, use Cypache, a security-first HTTP server for Python built on fastepoll.
Install
fastepoll is closed source and ships as that one binary wheel, on PyPI. Debian and Ubuntu leave the system Python to apt, so install it into a virtual environment (there, python3 -m venv needs the python3-venv package):
python3 -m venv /srv/app/venv
/srv/app/venv/bin/pip install fastepoll
- Python: CPython 3.12 or newer, tested on 3.12 to 3.15. Debian 12 and Ubuntu 22.04 come with older ones, so get 3.12 first (on Ubuntu from the deadsnakes PPA, anywhere with uv or pyenv). Free-threaded CPython (
python3.14t,python3.15t) has no such stable binary interface yet, so it gets a wheel per version. - System: Linux 5.3 on x86-64 or aarch64 (ARM64) with glibc 2.34 and OpenSSL 3, or newer: Debian 12, Ubuntu 22.04, RHEL 9 and later. pip won't install it on an older glibc.
Quick start
import fastepoll
class Echo:
def connection_made(self, transport):
self.transport = transport
def data_received(self, data):
self.transport.write(data)
fastepoll.run(Echo, "0.0.0.0", 8000)
Classes written for asyncio.Protocol and asyncio.DatagramProtocol work unchanged; subclassing them is optional.
Internet-facing servers: set the limits
Like asyncio, fastepoll has no idle timeout and no connection limit by default, so long-lived connections (chat, websockets) are never cut. A public server must set them, or idle and slowloris clients can hold file descriptors forever until new clients are refused:
fastepoll.run(App, None, 80, idle_timeout=30, max_connections=10_000, shutdown_timeout=10)
idle_timeout: seconds without any I/O progress before a connection is dropped (connection_lost(TimeoutError)). This includes a client that stops reading a response.max_connections: per worker; a full worker stops accepting and leaves new clients to the others. The total isworkers × max_connections, so raise the fd limit to match (LimitNOFILE=in systemd).- Protocol-level deadlines, such as "the HTTP headers must arrive within 10 s", belong in the protocol. Use
call_later(), see below.
Endpoints
run() serves a single TCP endpoint. serve() takes any mix, each with its own protocol factory:
fastepoll.serve([
fastepoll.TCP(Dns, None, 53),
fastepoll.UDP(DnsDatagram, None, 53),
fastepoll.Unix(Admin, "/run/app/admin.sock", mode=0o660),
], workers=0, idle_timeout=30)
-
TCP(factory, host=None, port, backlog=1024):host=Nonelistens on all interfaces, separately for IPv4 and IPv6. It can also be a name, a numeric address or a list. Nothing is bound beyond what you name. -
Unix(factory, path, backlog=1024, mode=None): a stale socket file left by a crash is replaced. A live socket, or a file that isn't a socket, is an error. The file is removed on exit.path + ".lock"stops two servers that start at once from taking each other's socket. It is held only until stopping begins, so a new instance can start while the old one drains.- The lock file stays behind on purpose. If it belongs to another user, remove it once.
modeneeds/proc, as on any normal Linux system.- Put sockets in a directory that only the server's user can write, such as
/run/app/.
-
A leading
"\0"makes the name abstract. Abstract sockets have no file permissions, so anyone in the network namespace can connect;modeis refused for them. -
sock=serves an already bound socket, in place ofTCP()'s host and port orUnix()'s path. The port then never goes free between server generations, and no client is refused while one replaces another:- from systemd socket activation, where systemd keeps the socket across restarts;
- from a launcher that keeps the socket and starts each new generation before stopping the old one. The old generation finishes its connections while the new one accepts. Between generations, clients wait in the socket's queue.
fastepoll makes its copy of the socket when
serve()starts, before any worker forks, and closes only that copy. It callslisten()if nobody has yet, and it never removes the file of aUnix()socket passed this way. Making the copy non-blocking also makes the caller's socket non-blocking, as the two share one open file.The socket itself stays the caller's, so fastepoll's fork handling leaves it alone. Workers inherit it, and so do processes forked from them. Programs they start inherit it too unless it is non-inheritable. Python's own sockets are non-inheritable; systemd's are not. Once
serve()has started, the caller may close its socket in any process, workers included, and the server keeps serving from its copies.# systemd socket activation: a .socket unit's sockets arrive as descriptors 3, 4, ... if os.environ.get("LISTEN_PID") != str(os.getpid()): raise SystemExit("start this through its systemd .socket unit") socks = [socket.socket(fileno=3 + i) for i in range(int(os.environ["LISTEN_FDS"]))] for s in socks: s.set_inheritable(False) # not passed on to programs the workers start fastepoll.serve([fastepoll.Unix(App, sock=s) if s.family == socket.AF_UNIX else fastepoll.TCP(App, sock=s) for s in socks])
-
Bind errors name the address, for example
[Errno 98] Address already in use: '0.0.0.0:443'. -
UDP(factory, host=None, port): the supervisor keeps oneSO_REUSEPORTsocket per worker for its whole life. The kernel keeps a client on the same worker, also across worker restarts, and no other process can take the port meanwhile. A port already in use is an error.
TLS
tls = fastepoll.TLS("/etc/letsencrypt/live/example.com/fullchain.pem", "/etc/letsencrypt/live/example.com/privkey.pem", sni={"*.example.org": ("org-chain.pem", "org-key.pem")}, alpn=["http/1.1"])
fastepoll.serve([fastepoll.TCP(Https, None, 443, tls=tls), fastepoll.TCP(Redirect, None, 80)], idle_timeout=30, max_connections=10_000)
- Protocols see plaintext.
connection_made()comes after a successful handshake, and failed handshakes (scanners, plain HTTP on port 443) never reach protocol code. - Defaults:
- TLS 1.2 and newer (
min_version="1.3"requires 1.3). TLS 1.3 uses OpenSSL's choices, including post-quantum hybrid key exchange where the OpenSSL version has it. TLS 1.2 is limited to forward-secret AEAD suites (ECDHE with AES-GCM or ChaCha20). - No renegotiation, no 0-RTT data (it can be replayed), no compression.
- Private keys must be unencrypted; an encrypted key fails to load rather than prompting for a pass phrase.
- TLS 1.2 and newer (
- Handshake limits: clients get
handshake_timeoutseconds (aserve()option, default 10) to finish. A stalled handshake costs about 60 KB and an fd until then, so public servers should also setmax_connections. - Connection details:
get_extra_info()addstls_version,cipher,alpn_protocol,server_name(the client's SNI) andktls. SNI is limited to printable ASCII at the handshake. - Who reads the certificate files: only the supervisor, at startup, on SIGHUP (
systemctl reload) and at every ticket-key rotation. Workers load what passed its checks, also after a crash restart, so a renewal that doesn't load is never served. Replace files by renaming new ones over them, as certbot does: a file read halfway through being rewritten in place can look whole, such as a chain without its intermediate yet. - Certificate renewal: new connections get the new certificates and existing ones keep theirs. If any of the new files doesn't load, all the previous ones stay in use, client CAs and CRLs included, and the error is printed. For certbot:
--deploy-hook "systemctl reload yourservice". - SIGHUP only ever reloads; it never stops the server, not even when a terminal closes.
- Session resumption: TLS 1.3 sessions resume on any worker. The ticket keys are shared, rotate every
ticket_lifetimeseconds (default 3600) and on every reload, and tickets stay valid for one more rotation. TLS 1.2 clients get no tickets, as a TLS 1.2 ticket would carry the session's master secret. Only those that don't ask for tickets resume (from the worker's own session cache), so most TLS 1.2 clients do a full handshake each time. - Client certificates (mTLS):
TLS(..., client_ca="clients.pem")makes clients present a certificate that chains to a root CA in that PEM file. Clients send their intermediates, or the file holds them. Withclient_cert="optional", clients may also connect without one, but a certificate that is sent must still be valid.get_extra_info("peercert")gives the certificate as the same dict asyncio gives (Nonewithout one), andget_extra_info("peercert_der")its DER bytes. To admit only one intermediate's clients, check the dict'sissuer.- CRLs in the same file are checked against every certificate in a client's chain, so revoking an intermediate refuses all its clients. Every CA then needs a CRL, and once one expires, the clients it covers are refused: renew it and reload.
- The file is re-read along with the certificates. A session made under other CAs or CRLs doesn't resume, so a revoked client can't come back on an old session ticket.
- No OCSP stapling: OCSP is being retired. The CA/Browser Forum made it optional, Let's Encrypt has shut its responders down, and revocation now goes through CRLs, which need nothing from the server.
- Truncation: a peer that closes without TLS's
close_notifylooks like a normal EOF. Protocols should delimit their messages, as HTTP does withContent-Length. - Kernel TLS: the kernel does the encryption, and
sendfile()stays zero-copy, where TLS 1.3 key updates work with it. That needs the kernel'stlsmodule, a kernel that takes new keys on a live connection (Linux 6.14, or a backport; fastepoll tries it at startup), and OpenSSL 3.2 or newer. Otherwise OpenSSL encrypts, andsendfile()reads files in 64 KiB chunks.- Debian 13's stock 6.12 kernel and RHEL 9's can't take new keys; a 6.14+ kernel, such as Debian's from trixie-backports, can.
- OpenSSL 3.0, as on Debian 12 and Ubuntu 22.04 and 24.04, never hands the kernel the new keys. Only root can make the kernel load the module on demand, so put
tlsinto/etc/modules-load.d/for servers that don't run as root. - Kernel TLS sends files faster but writes from memory slower: in a loopback benchmark,
sendfile()ran 60–85% faster and 2–16 KB writes 13–36% slower.TLS(..., ktls=False)can pay off for an endpoint that mostly sends generated content.
- No half-close, as in asyncio:
can_write_eof()is false andwrite_eof()raisesNotImplementedError. - OpenSSL is the system library (
libssl.so.3), so the distribution's security updates apply to fastepoll as well.
Transports
Stream transports provide the asyncio methods:
- writing:
write,writelines,write_eof,can_write_eof - closing:
close,abort,is_closing - flow control:
pause_reading,resume_reading,is_reading,get_write_buffer_size,get_write_buffer_limits,set_write_buffer_limits, withpause_writing/resume_writingon the protocol - the rest:
get_extra_info("peername" | "sockname"),get_protocol,set_protocol
They also offer:
transport.sendfile(file, offset=0, count=None): zero-copy sending of a regular file range, queued in order with writes.fileis a file object or a file descriptor, and may be closed as soon as the call returns.- If the file shrinks before its bytes are sent, the connection is aborted with
OSError(EIO): the peer sees it end early, never a short file followed by later output. Replacing a file by renaming a new one over it is safe, since the queued copy keeps the old one. - Queued file bytes count toward the write buffer, so
pause_writing()tells the protocol to stop taking requests. - More than 16 queued files on one connection, or more than a quarter of the fd limit across a process, raise
OSError(ENOBUFS). - Protocols that serve files must honour
pause_writing(), by callingpause_reading()untilresume_writing(). Otherwise clients that never read can use up that process-wide quota. - Reading a file that isn't in the page cache blocks the worker on disk I/O, as any read would.
- If the file shrinks before its bytes are sent, the connection is aborted with
As in asyncio, write() sends at once, so each call costs a system call (and with TLS, a record). Hand over pieces produced together, such as HTTP/2 frames, in one write() or writelines(): in a benchmark, a reply made of 21 small writes ran 10× slower than the same bytes in one.
Datagram transports provide sendto(data, addr), close, abort, is_closing, get_write_buffer_size and get_extra_info("sockname").
Differences from asyncio:
- No coroutines. Everything is a callback, plus
call_later()andcall_soon_threadsafe().get_running_loop()gives the worker's loop, with just those two methods. - An optional
shutdown_requested()on the protocol is called when the server stops (see shutdown). - Exceptions in a protocol callback print a traceback. They abort that connection, or drop that datagram; the worker keeps running. Only
SystemExitandKeyboardInterruptstop the worker, also from the program's own signal handlers, which run in worker processes as they would anywhere else (in threads mode, on the supervising thread). - Traceback output is rate-limited: at most 10 tracebacks per 10 s per worker. The rest are counted, and the count is printed later, so clients can't flood the log.
connection_lost()can come up to 5 s afterclose()(or afteridle_timeout, if that's shorter). The server sends FIN and waits for the client to close, because closing with unread input would make the kernel reset the connection and cut off the response.sendto()takes numeric addresses only, because name resolution would block the loop. The send queue is capped at 1 MiB; beyond that,error_received(OSError(ENOBUFS))is called instead.
Timers and threads
class Http:
def connection_made(self, transport):
self.transport = transport
self.headerTimer = fastepoll.call_later(10, transport.abort) # slowloris guard
def data_received(self, data):
...
if headersComplete:
self.headerTimer.cancel()
Transports and call_later() belong to the worker's loop thread, and calls that act on a connection raise RuntimeError from another thread. Plain getters such as get_protocol() and get_extra_info("peername") work anywhere. Another thread (for example, a pool thread making a blocking database call) hands its result back through the loop:
class Query:
def connection_made(self, transport):
self.transport, self.loop = transport, fastepoll.get_running_loop()
def data_received(self, data):
pool.submit(lookup, data).add_done_callback(lambda f: self.loop.call_soon_threadsafe(self.transport.write, f.result()))
fastepoll.call_soon_threadsafe() does the same with the process's loop, but in threads mode a process runs several, so from other threads it raises RuntimeError there.
Threads mode
serve(..., threads=True) runs the workers as threads of the calling process instead of as processes. On free-threaded Python (3.14t and newer) they run Python in parallel and share memory, so caches and connection pools can live in the process instead of in Redis. On other Pythons the GIL lets one thread at a time run Python, which suits testing more than serving.
fastepoll.serve([fastepoll.TCP(App, None, 8000)], threads=True, idle_timeout=30, max_connections=10_000)
- The calling thread supervises, as the supervisor process does otherwise, so it must be the main thread: it handles signals and TLS reloads, and restarts a worker thread that ends (for example, on a
SystemExitfrom a protocol).workers=0starts one thread per CPU, as with processes.- The program's own signal handlers run on this thread too, as Python's always run on the main thread. An exception from one ends
serve(), aborting the workers. - A worker that can't be started, say for lack of file descriptors, is tried again every second.
- The program's own signal handlers run on this thread too, as Python's always run on the main thread. An exception from one ends
- Shared state needs locks where several workers read, change and write it back, as in any threaded program. Each protocol only ever runs on its own worker's thread.
- Transports belong to their worker's thread. To write to another worker's connection, post to that worker's loop with its
call_soon_threadsafe(), as above. - One process: a crash ends all the workers, so have systemd restart the service (
Restart=on-failure). A worker thread stuck in a callback can't be killed: at the shutdown deadline it is left behind,serve()returns, and the process should then exit. - Limits:
max_connectionsis per worker thread, and thesendfile()queue limit applies to the whole process. - Forking from a threaded process is unsafe in general, and Python warns about it. fastepoll still closes the server's sockets in the child.
Processes, signals and shutdown
- Workers:
workers=0starts one process per CPU the server may run on (its CPU affinity, astasksetor a container sets it);workers=1runs in the calling process, which is handy for debugging. Workers share nothing, like PHP-FPM, so shared state belongs in Redis, a database or similar, unless they are threads (threads mode). - Accepting: TCP and Unix listeners are shared by all workers, and only an idle worker is woken for a new connection.
- Crashes: a worker that crashes is logged and restarted. Workers also exit if the supervisor dies.
- Forking: a process
fork()ed from a worker (a background job,multiprocessing) closes all of the server's sockets in itself. So it can't keep the port, or client connections, alive after the server is done with them.- Such a child must end with
os._exit(). One that returns into the server loop instead (say, because its job raised) is ended there with a message, rather than running the rest of the program.
- Such a child must end with
- Shutdown:
- SIGTERM or SIGINT stops accepting, and each connection's protocol gets
shutdown_requested()if it has that method. The protocol then finishes its work and closes the connection itself. An HTTP server, for example, closes idle connections right away, and the others after the response in progress. A connection accepted just before stopping began may still have its first request on the way, so closing it as idle loses that request. TLS handshakes under way may still finish withinhandshake_timeout. Their protocols then getconnection_made(), any data that came with the handshake, andshutdown_requested(). Timers andcall_soon_threadsafe()keep working meanwhile. - Connections whose protocol lacks
shutdown_requested()are closed: their buffered output is delivered first. - Whatever remains after
shutdown_timeoutis aborted. - A second signal, or SIGQUIT, aborts immediately.
- A worker stuck in a handler is killed 2 s after the deadline. With
workers=1there's no supervisor to do that, so the third signal ends a stuck process (as does systemd's SIGKILL afterTimeoutStopSec).
- SIGTERM or SIGINT stops accepting, and each connection's protocol gets
- Stopping the supervisor alone: a SIGTERM sent only to the supervisor stops all its workers and lets them drain.
- systemd: the default
KillMode=control-groupis fine; workers drain gracefully even though systemd also signals them directly.KillMode=mixedworks too: systemd then sends SIGTERM to the supervisor alone, and the final SIGKILL reaches any worker still running afterTimeoutStopSec. - Launchers: a process may import fastepoll and then fork the processes that call
serve(), for example one per server generation. fastepoll's fork handling affects only processes that are serving, or were forked from one.
[Service]
ExecStart=/srv/app/venv/bin/python /srv/app/server.py
LimitNOFILE=65536
TimeoutStopSec=20
Metadata
Release files for fastepoll 2.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distributions (wheels)
Total release size: 2.4 MB
Release files / fastepoll-2.1.0-cp315-cp315t-manylinux_2_34_x86_64.whl
| Download URL | fastepoll-2.1.0-cp315-cp315t-manylinux_2_34_x86_64.whl |
|---|---|
| Size | 432.3 kB |
| Tags | CPython 3.15 CPython 3.15 free-threading Linux glibc 2.34+ x86-64 |
|
SHA-256 checksum How to use checksums |
5b0cc1cc66857a3ef123b9b8a654b5bbaabf151882489454f4b05e3828bc4844
|
|
BLAKE2b-256 checksum How to use checksums |
47b4ddde6a2d2b3476b1e606950ccfa198af7de4c769dcaa8588767bddcbd765
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.4
|
Release files / fastepoll-2.1.0-cp315-cp315t-manylinux_2_34_aarch64.whl
| Download URL | fastepoll-2.1.0-cp315-cp315t-manylinux_2_34_aarch64.whl |
|---|---|
| Size | 395.2 kB |
| Tags | CPython 3.15 CPython 3.15 free-threading Linux glibc 2.34+ ARM64 |
|
SHA-256 checksum How to use checksums |
6bb6893b9aec88a19616bb47372b520d76da2aedb67ff6ab839c740eea414c52
|
|
BLAKE2b-256 checksum How to use checksums |
1d89500055a3c3a2101b3178ff0e61729fcd2e18a889a9cf6951580db5bddb0a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.4
|
Release files / fastepoll-2.1.0-cp314-cp314t-manylinux_2_34_x86_64.whl
| Download URL | fastepoll-2.1.0-cp314-cp314t-manylinux_2_34_x86_64.whl |
|---|---|
| Size | 433.3 kB |
| Tags | CPython 3.14 CPython 3.14 free-threading Linux glibc 2.34+ x86-64 |
|
SHA-256 checksum How to use checksums |
7c6b76b3f17248eb10933a0b6715f1f02340a6dc31fbe0a5ddba82fb0a0b3ac1
|
|
BLAKE2b-256 checksum How to use checksums |
740d1c0665d7b012686a64db80c782dcf0b417caa723300dc2b7eb9bdaae9d49
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.4
|
Release files / fastepoll-2.1.0-cp314-cp314t-manylinux_2_34_aarch64.whl
| Download URL | fastepoll-2.1.0-cp314-cp314t-manylinux_2_34_aarch64.whl |
|---|---|
| Size | 395.4 kB |
| Tags | CPython 3.14 CPython 3.14 free-threading Linux glibc 2.34+ ARM64 |
|
SHA-256 checksum How to use checksums |
3f8a4a052e8ab7c14681d6cdc8c934e65ba2fbe914637b6e268dbee1281df493
|
|
BLAKE2b-256 checksum How to use checksums |
e4e60369e18992c93042b6d675e4680b6b3b7a4e3c7166a617d6a0e1e19146ec
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.4
|
Release files / fastepoll-2.1.0-cp312-abi3-manylinux_2_34_x86_64.whl
| Download URL | fastepoll-2.1.0-cp312-abi3-manylinux_2_34_x86_64.whl |
|---|---|
| Size | 383.4 kB |
| Tags | CPython 3.12 Linux glibc 2.34+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
fbb05dfdd8b3523c545c2931fcce9d472480b731ab9fc8d3db6e51817593d637
|
|
BLAKE2b-256 checksum How to use checksums |
7eee239875ceeb65fa9704caffbcd06da7d39a0a012e7a9032fbc5a35cc73643
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.4
|
Release files / fastepoll-2.1.0-cp312-abi3-manylinux_2_34_aarch64.whl
| Download URL | fastepoll-2.1.0-cp312-abi3-manylinux_2_34_aarch64.whl |
|---|---|
| Size | 344.8 kB |
| Tags | CPython 3.12 Linux glibc 2.34+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
c928a4cfdf89c42da9bcc67f671526bda4c0cfb444444c418310337e8cbedf2a
|
|
BLAKE2b-256 checksum How to use checksums |
5bb222680eb7ce401614bffa042f07b019bc63683b1ba734af6458bab63b513f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.4
|
Security for fastepoll 2.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.