Skip to content

About

Python bindings for zmux: a QMux-compatible stream multiplexer with multipath, lanes, 0-RTT and session resumption over TCP, TLS, QUIC, WebSocket and HTTP.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

zmuxio

CI PyPI Python versions License

Python bindings for zmux: multiplexed streams and datagrams over one or many reliable byte pipes, wire-compatible with QMux (draft-ietf-quic-qmux). With a plain QMux peer a session is a single-pipe QMux session; between two zmux endpoints it adds multipath, resumption, stream preambles, priorities and datagram flows.

The protocol and all I/O run in the Rust implementation on a runtime managed by the extension; Python awaits the results on its asyncio loop.

pip install zmuxio      # import zmux; Python 3.12+, also free-threaded 3.14t

Prebuilt wheels: Linux (manylinux) x86_64, aarch64, armv7, i686, ppc64le, s390x and riscv64; Linux (musllinux) x86_64, aarch64, armv7 and i686; macOS x86_64 and arm64; Windows x86_64 and x86. Other platforms (Windows on arm64, for example) install from the sdist, which needs a Rust toolchain.

import asyncio, zmux


async def main():
    async with zmux.Listener() as listener:
        addr = await listener.serve_tcp("127.0.0.1:0")

        async def serve():
            async for session in listener:
                async for send, recv in session:
                    await send.write(await recv.read_to_end())
                    await send.finish()

        server = asyncio.create_task(serve())
        async with await zmux.connect_tcp(addr) as session:
            send, recv = await session.open_bi(info=b"echo")
            await send.write(b"hello")
            await send.finish()
            print(await recv.read_to_end())
        server.cancel()


asyncio.run(main())

Carriers: TCP, TLS (TlsConfig from PEM paths or bytes; clients default to the bundled web roots), QUIC (with lanes), WebSocket, HTTP/1.1, HTTP/2 (Http2Connection opens several pipes on one connection), the HTTP exchange carrier, HTTP/3 and WebTransport. dial_x(target, *, ...) gives a PipeIo, connect_x(target, *, tls=None, config=None, ...) a Session, and Listener.serve_x(addr, *, ...) accepts. pipe_from_streams(reader, writer) runs a session over any asyncio stream pair (slower: bytes pass through Python); the transport's get_write_buffer_size() is its unsent count. PipeIo.with_unsent(count) gives any carrier such a count (PipeStats.queued_bytes, the carrier's part of the pipe's queue): a zmux.UnsentCount the application sets at each write, which the session reads without the interpreter lock.

session = await zmux.connect_quic(
    "example.net:443", config=zmux.Config(max_idle_timeout=30)
)
async with session:
    send, recv = await session.open_bi(info=b"route", priority=zmux.Priority(1))
    async with send:  # finish on exit, reset on error
        await send.write(b"request")
    async for chunk in recv:  # until the end of the stream
        ...

HTTP exchange carrier, for paths that only pass plain request/response HTTP (no upgrades, no streaming bodies): the client sends POST requests, the server answers on serve_http. Both ends share psk and encoding:

opts = zmux.ExchangeOptions(
    psk=b"shared secret",
    encoding="base64",
    path="/up/{instance}",
    down_path="/down/{instance}/{nonce}",
)
await listener.serve_http("0.0.0.0:8080", exchange=opts)  # server
session = await zmux.connect_exchange("example.net:8080", options=opts)  # client

dial_exchange(addr, *, options, down_addr=None, tls=None) sends DOWN requests to another endpoint when down_addr is given.

One endpoint serves every HTTP carrier on one path, each request told apart by its head: serve_http(addr, exchange=..., websocket=True) takes bidirectional POSTs (dial_http), uplink POSTs and downlink GETs (dial_http(..., carrier="uplink" | "downlink", join=...)), split bootstraps (connect_http_split: a POST and a GET paired by a token, for paths that stream each body but not both on one request; run them over TLS), exchange requests and WebSocket upgrades. Carrier requests forwarded from TLS early data (Early-Data: 1) are answered 425.

Carrier modes choose among kinds for paths through reverse proxies and CDNs: Mode.explicit(candidate) uses one kind and reports its failure; Mode.auto([...]) tries candidates in order of preference (no early data, no racing), remembers per key in a CarrierCache which kinds passed, and with max_pipes above 1 starts on the most robust one and moves to a preferred kind that joins. The choice and every try are session events (CarrierChosen, CarrierCandidate) and in session.stats():

auth = zmux.PrefixAuth(b"shared secret", slot=30)
mode = zmux.Mode.auto(
    [
        zmux.Candidate.websocket("wss://example.net/zmux", credential=auth),
        zmux.Candidate.streaming("example.net:80", credential=auth),
        zmux.Candidate.split("example.net:80", credential=auth),
        zmux.Candidate.exchange("example.net:80", options=opts),
    ]
)
session = await zmux.dial_mode(
    mode, zmux.Config(), cache=cache, key="example.net|office"
)
print(session.stats().carrier)

PipePool.mode(mode, count=2, cache=cache, key=...) keeps further members over a mode's kinds the same way (session.maintain_pipes(pool)): each dialed over the most robust kind available, then upgraded make before break to a preferred kind once it stayed usable for settle. The cache outlives the process as JSON text: text = cache.snapshot(), later cache.restore(text) (merged, newer entries win; the form is shared by every zmux implementation).

Behind trusted intermediaries, Listener(trusted_proxies=["10.0.0.0/8"], forwarded_header="X-Forwarded-For", proxy_protocol=False) takes a carrier's source (per-source limits, admission) from what they report.

Admission: Listener(admission=...) decides on each new carrier at every entry (each serve_x, and handle_pipe for joins and resumptions as for new sessions) before anything is answered, and dial_x(..., credential=...) presents what it checks. Every carrier first takes a slot of its source's max_pending_per_source (refused at once beyond it), then its outer handshake (TLS, HTTP upgrade, QUIC), then the admission, then its first record. PrefixAuth tokens are the shared mechanism: a token is the carrier's first bytes, or the Zmux-Prefix-Auth request header on WebSocket and WebTransport; with bind_tls=True it is bound to the carrier's TLS or QUIC handshake. A carrier without a valid token goes to the fallback (it is held open, then closed):

auth = zmux.PrefixAuth(b"shared secret", slot=30, bind_tls=True)
listener = zmux.Listener(admission=zmux.Admission.prefix_auth(auth, skew=1))
await listener.serve_websocket("0.0.0.0:443", tls=server_tls)
pipe = await zmux.dial_websocket("wss://example.net/", credential=auth)

Admission(decide, *, peek=N) takes a decision of your own: decide(info, prefix) gets the CarrierInfo (source and peer as (host, port), alpn, server_name, headers of HTTP carriers, export(label) for the TLS exporter) and up to N first bytes, and returns a Verdict (Verdict.accept(skip, value=...), FALLBACK, STALL, CLOSE), once per carrier. An accept's value, any object (a user record, say), is the carrier's admitted value: session.admitted (the carrier that started the session), pipe.admitted (the carrier that brought the pipe) and the inbound transform's hint.admitted. A plain function runs on a native thread holding the GIL: keep it quick and never block in it. An async def decide runs on the event loop the Listener was made in, within what remains of the peek timeout, and is cancelled when that ends or the listener closes. An exception closes the carrier. listener.stats().admission (AdmissionStats) counts each outcome once: accepted, fallback, stalled, closed, stall_overflow, peek_timed_out, handshake_failed. await listener.shutdown() refuses new sessions while the live sessions' joins and resumptions are still taken until they end; close() refuses everything at once. A preamble beyond the peer's limit raises PreambleTooLargeError (a ValueError). A credential of your own is any object with prefix(info) -> bytes and header(info) -> (name, value) (zmux.Credential).

Listener(inbound_transform=f) runs f(pipe, hint) once for every carrier after its admission and before its first record (each half of a split bootstrap included) and goes on with the carrier it returns, for a layer of your own over every carrier. await pipe.wrap(layer) runs layer(reader, writer) over the carrier as asyncio streams and keeps the carrier's framing, kind and other properties; each carrier write is one write on the layer's writer, so the layer must turn each write into exactly one write below and never merge writes (asyncio's TLS does):

async def tls(reader, writer):
    await writer.start_tls(server_context)
    return reader, writer


async def transform(pipe, hint):
    return (await pipe.wrap(tls, server_side=True)).with_write_overhead(22)


listener = zmux.Listener(inbound_transform=transform)

The client runs the same layer on its carrier before the session (await (await zmux.dial_tcp(addr)).wrap(client_tls)). with_write_overhead(n) tells the shaper the bytes the layer adds per write. zmux.open_carrier(pipe) gives a carrier as a plain asyncio stream pair. A transform's exception, a carrier of another kind or the handshake timeout closes the carrier as a setup failure (listener.stats().setup_failures). Listener(answer_unknown_joins=True) answers a join that names no session (with the listener's instance_id prefix) with TransportErrorCode.RESUME_FAILED, so a suspended joiner ends at once instead of at its resume timeout; enable it only where such locators reach no other instance.

Streams: finish() ends the sending half, reset(code) abandons it, recv.stop(code) asks the peer to stop sending. Every coroutine is cancellation-safe; use asyncio.timeout() for deadlines (durations are seconds). open_connection(session) returns an asyncio-style (reader, StreamWriter) pair whose drain() applies backpressure.

Events and statistics:

async for event in session.events():
    match event:
        case zmux.PipeStateChanged(
            pipe=p, state=zmux.OutboundState.FAILED, failure=why
        ):
            log.warning("pipe %d failed: %s", p, why)
        case zmux.Suspended() | zmux.Resumed():
            log.info("%s", event.kind)
print(session.stats().bytes_sent, [s.smoothed_rtt for s in session.pipe_stats()])

A direction's state is a zmux.OutboundState (JOINING, ACTIVE, SUSPECT, DRAINING, FAILED, CLOSED) or a zmux.InboundState (JOINING, OPEN, ENDED): PipeStateChanged.state by its half, PipeStats.outbound and PipeStats.inbound. Retirement is reported only by PipesRetired (retired, failed, closed, dropped counts since it was last read and last, up to four IDs, oldest first). A retired pipe's stats() and state are None, and its operations raise PipeError.

Statistics are snapshots with the same fields as in the other languages: session.stats() (SessionStats: carrier and application bytes, streams, datagrams, RTT, multipath counts), session.pipe_stats() and pipe.stats() (PipeStats, with lanes), send.stats() and recv.stats() (StreamStats, the same snapshot for both parts, kept with its final values once the session released the stream) and listener.stats() (ListenerStats).

Streams: try_open_bi() opens without waiting (OpenError without credit); open_bi(info=, tlvs=, pipe_carrier=, priority=, peer_priority=) fixes the preamble before the first frame. await recv.read_chunk(n) returns a memoryview of the received buffer and await send.write_chunk(buf) takes any buffer without copying it. send.send_capacity() and await send.wait_send_capacity(n) tell how much the part can transmit; set_send_buffer, set_scheduler, set_redundant (True, False, or None to follow the session's redundancy), set_reliable_boundary and send_datagram(payload, context=0) are on SendStream; set_receive_window, set_peer_priority, await read_datagram() and reject() on RecvStream.

Relays and nested sessions: await zmux.splice((send_a, recv_a), (send_b, recv_b), a_to_b=limiter) forwards two streams into each other (a zmux.Limiter rate-limits a direction) and returns a Spliced; await session.open_pipe_stream(tlvs) opens a carrier stream and zmux.stream_pipe(send, recv) wraps an accepted one, either a PipeIo for Session.connect/accept or add_pipe. zmux.resolve_duplicate(s1, s2, local_identity=, peer_identity=) keeps one of two sessions that raced between the same endpoints.

Datagrams: Config(enable_datagrams=True), with the queue sizes datagram_send_queue (64 datagrams waiting to be sent), datagram_receive_queue (128 received session datagrams), datagram_stream_queue (32 per stream) and datagram_receive_bytes (1 MiB of received datagrams in all); a full queue drops its oldest.

Schedulers: Config(scheduler=...) and send.set_scheduler(...) take the name of a built-in policy ("affinity", "session-affinity", "spread-eta", "spread-round-robin", "spread-rate-weighted"). A policy written in Python is not offered: it would run on the driver's decision path under the interpreter lock.

Multipath: give both ends Config(max_pipes=8, pipe_auth=zmux.PipeAuth.psk(key)) (or PipeAuth.exporter() over TLS/QUIC, PipeAuth.exporter().with_psk(key) for both), then session.add_pipe(await zmux.dial_tcp(addr), options=zmux.PipeOptions(send_priority=1, receive_priority=1)). PipeOptions holds every option of a pipe (None: unset); a live pipe changes its priorities, standby flags, weight and probation together with pipe.update(weight=2, send_priority=None) (None clears send_priority and send_standby; fixed or unknown fields raise TypeError, invalid values ValueError), and pipe.options returns the options in force. A split carrier joins as two further pipes, a downlink and an uplink: await zmux.join_http_split(session, addr) (its bootstrap carrier from dial_http_split makes pipe pair 0 only). For an out-of-band join, r = session.reserve_pipe(), send r.header as the Zmux-Pipe-Join request header (dial_websocket(url, headers={"Zmux-Pipe-Join": r.header})), then r.attach(pipe). Resumption: Config(resume_timeout=..., dead_timeout=...), and session.revalidate_pipes() after a network change.

Idle pipes are probed (their heartbeat): an outbound pipe that has sent nothing ack-eliciting for Config(probe_interval=...) seconds (default 15, at least 1; None turns these probes off) gets a probe, each wait drawn from [k * (1 - j), k] with Config(probe_jitter=j) (default 0.25; 0 gives a fixed schedule), the first wait of the bootstrap pipe from [0, k]. A pipe's own add_pipe(..., options=zmux.PipeOptions(probe_interval=..., probe_jitter=...)) overrides them (interval at least 1 s, a one-way attachment at most 15 s; PipeOptions(idle_probes=False) turns the pipe's idle probes off). Probes do not keep a session from its idle timeout; keep-alive does (Config(keep_alive_period=...), default 15 s, capped at half the idle timeout and at least 1 s, jittered like the probes; None turns it off, and the idle timeout then ends a session without application activity). The timers and periods of Config hold their defaults and take None for no timeout or off: handshake_timeout, max_idle_timeout (at least 0.001 s; None announces no idle timeout), keep_alive_period, standby_probe and probe_interval; a period of 0 raises. Every other field keeps its default when None; math.inf makes memory_budget unlimited (also for Listener(memory_budget=...)), and a budget of 0 is no byte. PipeStats.since_last_traffic is the time since stream data or a datagram last went either way on the pipe. bytes_lost / records_lost are what a failed pipe left unacknowledged, bytes_retransmitted the carrier's own retransmissions (TCP on Linux, Android, Apple platforms and Windows; QUIC) and loss_rate its smoothed estimate.

Pools: handle = session.maintain_pipes(zmux.PipePool(count, dial, ...)) keeps count usable pipes dialled by the async dial(), redials failed ones with backoff from the start of the previous attempt and, with rotate_max_bytes / rotate_max_age (seconds, reduced by up to rotate_jitter), replaces them make before break; handle.stop() ends it. With join= in place of dial (an async join(session, options) returning the pipes it joined, such as join_http_split), each member is a group of pipes, usable while all of them are, counted once and redialled or rotated whole. It looks again at once when a pipe changes state or the session is suspended or resumed, and keeps dialling while suspended. options= are the PipeOptions of the pool's pipes. pool_policy= takes a zmux.PoolPolicy or its JSON (str, bytes or dict), evaluated natively at each look of the maintainer:

policy = {
    "count": {"const": 2},
    "dial_after": [{"const": 1}, {"range": [5, 10]}],
    "retire": [{"metric": "age", "above": {"range": [600, 900], "scope": "pipe"}}],
}

Values have the JSON form and validation of the shaping definition's values; times are in seconds. count replaces the pool's count, rounded; it cannot draw below 1 (a distribution that can, such as normal, needs a min of at least 1). Entry min(failures, len - 1) of dial_after is the delay after the last dial for that many consecutive failed dials, in place of the backoff's (the backoff maximum bounds it; a negative delay makes the decision invalid); dial_jitter (0 to below 1) reduces each wait by its own random fraction of up to it. A pipe is replaced when any retire rule's metric is strictly above its threshold, reduced by a fraction of up to the rule's optional jitter drawn once per pipe; metrics are age, idle (the pipe's since_last_traffic), pipes (1 per pipe), bytes_sent, bytes_received and bytes (their sum). probe_interval (a value, at least 1 s) and probe_jitter set the probes of the pipes the pool adds from then on. Scope shared draws once per policy, pipe once per pipe while it is in the pool (only in retire), each or empty at every decision. Absent members keep the pool's own behaviour; unknown members, metrics or scopes raise ValueError with the native message. The maintainer also looks again by itself when an age or idle rule of a kept pipe is due or dial_after has elapsed. PoolPolicy.decide(failures, members, since_dial=s) runs one decision directly and returns a PoolPolicyDecision (count, dial_after, retire, recheck: seconds until that next look, or None, probe_interval, probe_jitter); one made directly checks its probe settings (ValueError). A pool rejects a policy decision whose probe interval is neither 0 nor at least 1 s, or whose dial delay is negative, as a whole, keeps its own behaviour and calls PipePool(decision_rejected=f) with the reason on the event loop.

Defaults are exported as constants: DEFAULT_KEEP_ALIVE_PERIOD (15 s), DEFAULT_IDLE_TIMEOUT (30 s), DEFAULT_PROBE_JITTER (0.25), DEFAULT_STANDBY_PROBE (1 s), DEFAULT_MAX_RECORD_SIZE (16382), DEFAULT_BACKOFF_MAX (60 s, the largest redial delay of a pool), MIN_MAX_LEN (64, the smallest shaping max_len) and STARTUP_ALLOWANCE (16382).

Shaping: session.set_shaper(json=...) or set_shaper(sequence=...) shapes every outbound pipe by a definition, run natively; set_shaper() stops it and session.shaping_stats() returns its counters. limits=ShapingSizeLimits(...) bounds the definition (max_bytes, max_machines, max_states, max_actions, max_transitions, max_points; one beyond raises ValueError). A length, time, rate, burst or counter value of a definition that can draw below 0 (such as normal) needs a min of at least 0, or the definition raises ValueError. Config(shaping_limits=ShapingLimits(...)) bounds what the shaper may do over a session (max_padding_bytes, max_padding_ratio, max_injected, max_pause_total, max_delay in seconds; each finite and non-negative, max_padding_ratio at most 1, else ValueError at construction); a decision beyond is reduced to the bound and counted as clamped. In both, a field left None is unbounded and 0 allows none. Under a set ratio, STARTUP_ALLOWANCE bytes of PADDING are always allowed (without one only max_padding_bytes bounds PADDING), and a padding-only record the allowance does not cover waits and goes whole later. seed=b"..." replaces the operating system's generator by a deterministic one so that every pipe draws the same values; it is for tests only.

Shaping by carrier kind: every pipe has the carrier kind of its carrier (PipeStats.carrier, PipeStateChanged.carrier): websocket, streaming, split, exchange, quic, http3 or webtransport, set by the binding's dialers and listeners on both ends, or None for TCP and TLS. pipe_pair(carrier=...) and pipe.with_carrier(kind) label a carrier of your own. Config(shaping={CarrierKind.WEBSOCKET: json, None: json}) maps kinds (None: the default, for a pipe of no kind or of a kind not listed) to definitions in the JSON form; each session made with that configuration, accepted sessions and joined pipes included, shapes each pipe by its kind's definition from its first record, and a pipe without one is not shaped. session.set_shaper(by_kind={...}) replaces it on a running session. The choice runs natively; no Python code runs per record.

Early data: Config(early_data=True) toward a zmux peer sends the first streams before the peer's parameters arrive. QUIC 0-RTT: keep one QuicClient(tls, zero_rtt=True) with Config(carrier_early_data=True) at both ends and serve_quic(..., zero_rtt=True); session.zero_rtt reports what happened. Early data can be replayed, so servers should refuse non-idempotent requests on replayable streams:

async for send, recv in session:
    if recv.is_replayable and not idempotent(recv.preamble.info):
        await recv.stop(1)
        await send.reset(1)
        continue
    ...

On EarlyDataRejected (retryable is true) open the streams again on a new session without early data, and QuicClient.forget(server_name).

Errors derive from zmux.ZmuxError with kind (ErrorKind), retryable and code; SessionClosed adds reason and remote.

License: Apache-2.0.

About

Python bindings for zmux: a QMux-compatible stream multiplexer with multipath, lanes, 0-RTT and session resumption over TCP, TLS, QUIC, WebSocket and HTTP.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages