Status: Proposal for review. This document is the plan only. No code has been moved or written yet. Once approved, it becomes the blueprint for the restructure and the per-language implementations, and the protocol section is extracted into a standalone
PROTOCOL.md.
Turn aiomsg from a Python-only library into a multi-language family of
native implementations of one shared messaging protocol. Each language gets
its own top-level subdirectory and an implementation that is idiomatic to that
language — no C bindings, no FFI wrappers. A socket written in any language
must interoperate on the wire with a socket written in any other.
First three languages: Python (the existing reference, relocated), Rust (async first, then a separate sync crate), and Go.
The high-level design principles, feature set, and developer experience described in the README's Introduction are the fixed specification. They do not change. What changes is (a) the repo layout, (b) a formalized, cleaned-up wire protocol that all languages share, and (c) new native implementations.
These come from the README and are the contract every implementation honours:
- Messages, not streams. Send/recv operate on whole, length-framed byte messages. Encoding of the payload is the user's concern.
- One socket type. No ZMQ-style socket-pair zoo. There is a single
Socket. The only role distinction is bind vs connect. - Many connections, one object. A bind socket accepts many peers; a connect
socket may
connect()to many peers. Allsend/recvhappen on the one object. - Send-mode is the magic. The sending socket chooses distribution:
- PUBLISH — every connected peer gets a copy.
- ROUNDROBIN — each message goes to one peer, cycling.
- By identity — send directly to a named peer, regardless of send mode.
- Automatic reconnection (the connect end reconnects, in either direction).
- Automatic send buffering when no peers are connected.
- Built-in heartbeating, opaque to the application.
- Delivery-guarantee choice:
AT_MOST_ONCE(default) vsAT_LEAST_ONCE. - TLS via each language's standard/idiomatic TLS facility.
- Effortless drop-in. A dev should be able to wire components together — even across languages — without thinking about the transport.
Cross-language API parity is itself a principle. The shape of the API (construct → bind/connect → send/recv/iterate → close) must feel the same everywhere, while the spelling is idiomatic per language (async/await in Python and Rust-async; goroutines + channels in Go; blocking calls + threads in Rust-sync).
aiomsg/
├── README.md # Updated: new structure; design principles unchanged
├── DESIGN.md # This document
├── PROTOCOL.md # Canonical wire spec (extracted from §5 here)
├── LICENSE
├── justfile # Top-level dispatch into each lib's tasks
│
├── python-lib/ # The existing implementation, relocated verbatim
│ ├── pyproject.toml
│ ├── aiomsg/ # package: __init__.py, msgproto.py, header.py, ...
│ ├── tests/
│ ├── examples/
│ ├── justfile
│ └── README.md # Python-specific notes; points back to root
│
├── rust-lib-async/ # tokio-based async implementation (FIRST new impl)
│ ├── Cargo.toml
│ ├── src/
│ ├── tests/
│ ├── examples/
│ └── README.md
│
├── rust-lib-sync/ # std::net + threads, blocking API (later phase)
│ ├── Cargo.toml
│ ├── src/
│ ├── tests/
│ └── README.md
│
├── golang-lib/ # goroutines + channels (later phase)
│ ├── go.mod
│ ├── aiomsg/
│ ├── examples/
│ └── README.md
│
└── conformance/ # Cross-language interop test harness (see §8)
├── runner/ # orchestrates pairs of language "agents"
├── scenarios/ # declarative interop scenarios
└── README.md
Notes:
- No shared Rust core. The two Rust crates are kept fully independent
(per decision). The protocol is simple enough that reimplementing the framing
and envelope logic in each is cheap, and avoiding a shared crate avoids the
coupling cost.
rust-lib-syncandrust-lib-asyncshare nothing. - Each language dir is self-contained: its own build/test tooling, its own
lockfile, its own CI job. Every language dir has a
justfileexposing a uniformjust testthat runs that implementation's test suite. The rootjustfileand CI fan out to each.
This is the abstract contract. §6 maps it onto each language idiomatically.
| Concept | Default | Notes |
|---|---|---|
send_mode |
ROUNDROBIN |
PUBLISH | ROUNDROBIN |
delivery_guarantee |
AT_MOST_ONCE |
AT_MOST_ONCE | AT_LEAST_ONCE |
identity |
random 16 bytes (UUIDv4) | stable per socket; used for routing & dedup |
reconnection_delay |
() -> 0.1s |
strategy/closure so users can add jitter/backoff |
heartbeat_interval / heartbeat_timeout |
5s / 15s | per the protocol; rarely overridden |
receiver_channelexisted in the Python constructor but was unused in the reference code. Decision: dropped from the canonical API.
| Operation | Meaning |
|---|---|
bind(host, port, tls?) |
Listen; accept many peers. |
connect(host, port, tls?, connect_timeout?) |
Connect (and auto-reconnect) to a peer; callable multiple times. |
send(data, identity?) |
Enqueue a message. Routed by send-mode, or to identity if given. Buffered if no peers. |
recv() -> data |
Next message from any peer. |
recv_identity() -> (identity, data) |
As above, with sender identity. |
messages() |
Stream/iterator/async-gen of data. |
identity_messages() |
Stream/iterator of (identity, data). |
close() |
Graceful shutdown: drain, stop tasks, close connections. |
| scoped lifetime | async with (Py) / RAII+Drop or explicit close().await (Rust) / defer Close() (Go). |
Convenience codecs (send_string/send_json/recv_string/recv_json) are
non-core and provided where idiomatic (always in Python; behind helpers or
generics in Rust/Go). They are sugar over send/recv and carry no wire
significance.
This is the heart of interop. It supersedes the README's "extremely provisional"
protocol section and resolves the admitted ambiguity in the AT_LEAST_ONCE
header. The Python reference will be updated to conform to this (per
decision: "clean canonical v1").
- On the wire: framing, the typed envelope (handshake, heartbeat, data, ack), and nothing else.
- Local only (never signalled):
send_mode(publish/round-robin/identity) anddelivery_guarantee. The receiver neither knows nor cares how the sender chose to route — it just receives frames. This keeps the protocol tiny and is why any send-mode interoperates with any other.
Every frame is length-prefixed:
frame = [u32 big-endian length N][N bytes: envelope]
The first byte of every framed payload is a message type. This is the key
fix: application data is wrapped, so it can never be mistaken for a control
message (the old design compared whole payloads against b"aiomsg-heartbeat"
and pattern-matched a \x00REQ\x00-style header, both of which a raw payload
could collide with).
envelope = [u8 type][type-specific body]
| Type | Name | Body | Purpose |
|---|---|---|---|
0x01 |
HELLO |
[u8 version][16-byte identity] |
Handshake. Sent first, immediately after TCP/TLS connect, by both ends. |
0x02 |
HEARTBEAT |
(empty) | Keepalive during idle periods. |
0x03 |
DATA |
[payload…] |
Application message, AT_MOST_ONCE. No ack expected. |
0x04 |
DATA_REQ |
[16-byte msg_id][payload…] |
Application message needing acknowledgement (AT_LEAST_ONCE). |
0x05 |
ACK |
[16-byte msg_id] |
Acknowledges receipt of a DATA_REQ. Not delivered to the app. |
msg_id is a fixed-width 16-byte field (UUIDv4 bytes) — positional, not
delimiter-based, so payload contents are irrelevant to parsing.
On every new connection, both ends, before anything else:
- Send
HELLOwithversion = 0x01and their 16-byte identity. - Read the peer's
HELLO.- If the peer's
versionis unsupported → log and close the connection. - If the peer's
identityis already connected to this socket → reject the duplicate (mirrors the reference's de-dup rule).
- If the peer's
This makes the protocol forward-evolvable: future versions can extend HELLO
(e.g. capability flags) without breaking v1 peers.
- Send a
HEARTBEATframe afterheartbeat_interval(5s) of send-inactivity. - If no frame of any type is received within
heartbeat_timeout(15s), the connection is considered dead and torn down. The connect end then reconnects (per §5.6).
- Sender in
AT_LEAST_ONCEmode emitsDATA_REQwith a freshmsg_id, starts a resend timer (5s default), and keeps the message pending. - Receiver, on any
DATA_REQ, delivers the payload to the app and sends anACKcarrying the samemsg_idback to the originating connection. - On
ACK, the sender cancels the pending resend. On timeout, it resends (bounded retry count). Acks are never surfaced to the application. - Constraint (from reference):
AT_LEAST_ONCEapplies toROUNDROBINand by-identity sends, notPUBLISH. Implementations reject/ignore the combo consistently. - Ordering caveat (documented behaviour): resends can arrive after later
messages;
AT_LEAST_ONCEdoes not guarantee order, only that a message is delivered one-or-more times. In-memory retry state is lost if both ends die.
- The connect end owns a long-lived reconnect loop: on disconnect it waits
reconnection_delay()and retries for the life of the socket. - When
send()is called with no connected peers, the message is buffered and flushed as soon as a peer connects. This holds for both send modes.
- Length-prefix every frame with a
u32BE length. - Emit/accept the typed envelope of §5.2.
- Perform the symmetric
HELLOhandshake with version check + identity de-dup. - Send heartbeats on idle; tear down on heartbeat timeout.
- Multiplex many connections behind one socket; merge receives.
- Implement
PUBLISH,ROUNDROBIN, and by-identity routing locally. - Buffer sends when no peers; auto-reconnect on the connect end.
- Reply with
ACKto everyDATA_REQ; implement sender-side resend.
The canonical API (§4) realized natively. Same shape, different spelling.
- Keep
async/await,async with, async generators, and the homage spellingSøcket(with a plainSocketalias for non-Unicode ergonomics). - Update
msgproto.py/header.pyto the typed envelope of §5.2 (this is the one behavioural change to the reference). - Public API otherwise unchanged — existing examples/tests keep working.
- Runtime: tokio. TLS:
tokio-rustls. - Construction via a builder:
Socket::builder().send_mode(..).build(). bind/connect/send/recvareasync fn.messages()returns a type implementingStream<Item = Bytes>(andidentity_messages()→Stream<Item = (Identity, Bytes)>).- Internals: an actor/broker task owns the connection map and the
send/route logic; per-connection reader/writer tasks communicate with it over
mpscchannels. (This mirrors the Pythonsender_task+ConnectionsDict, and is the clean version of what the oldorigin/rust-aiomsgbranch reached for with itsbroker_loop.) - Shutdown: explicit
close().await;Droptriggers best-effort teardown. - Errors: a
thiserrorenum; no panics on peer misbehaviour.
std::net::{TcpListener, TcpStream}+ threads. TLS:rustlswith the pure-Rustringprovider (no OpenSSL / C toolchain), behind a default-ontlsfeature.- Blocking
send/recv/recv_timeout;messages()returns anIterator<Item = Bytes>. A clonedSocket(it isSend + Sync) is how you send from a different thread than you receive on. (No callback receive API — kept consistent with the rest of the family and the reference's "no callback handlers" ergonomic.) - Internals: a central broker thread plus a reader thread + writer thread per
connection, communicating via
std::sync::mpsc. This gives zero-latency duplex (the writer sends the instant the broker queues a frame) for both plain TCP and TLS. A rustls connection can't be split across two threads, so the TLS path shares the rustlsConnectionbehind a short-lived mutex while doing the blocking socket I/O on atry_cloned fd ("separate socket I/O from TLS state"). The full rationale and the rejected alternatives (a polling loop; a hand-rolled epoll reactor; ablock_on-over-async wrapper) are written up inrust-lib-sync/docs/threading-and-tls.md. - Independent of the async crate — it reimplements the (simple) framing and
envelope logic itself; no shared core crate, and explicitly not a
block_onwrapper overrust-lib-async.
- Idiomatic goroutines + channels. Functional options:
aiomsg.NewSocket(aiomsg.WithSendMode(aiomsg.RoundRobin)). Bind/Connect/Send/Recv/Close.Messages()returns a<-chan Message(whereMessage{Identity, Data}), closed on socket close.- Internals map almost 1:1 onto the Python design: a router goroutine owns the
connection map; each connection has reader/writer goroutines;
chanreplacesasyncio.Queue;context.Contextdrives cancellation/shutdown. - TLS via
crypto/tls.
| Python (asyncio) | Rust async (tokio) | Rust sync (threads) | Go |
|---|---|---|---|
| task | task | thread | goroutine |
asyncio.Queue |
mpsc channel |
mpsc/crossbeam |
chan |
async for |
Stream |
Iterator |
range over <-chan |
Event |
Notify/watch |
Condvar |
chan struct{}/context |
await close() |
close().await + Drop |
close() + Drop |
Close() + defer |
Mechanical, low-risk, done as its own commit(s):
- Create
python-lib/;git mvtheaiomsg/,tests/,examples/,pyproject.toml,uv.lock,.coveragerc,RELEASING.mdinto it. - Fix paths in
pyproject.toml(module-root),.coveragerc, and the Pythonjustfile. - Update
.github/workflows/so the Python job setsworking-directory: python-lib(and add a path filter so it only runs on Python changes). - Add a thin root
justfilethat dispatches (just python::test, etc.). - Verify:
cd python-lib && uv run --group test pytestis green. - Update
README.md: new structure + per-language pointers; Introduction / design principles unchanged; replace the provisional protocol section with a link toPROTOCOL.md. - Apply the §5 typed-envelope change to the Python wire code as a separate commit (behavioural change, gets its own review and full test run).
Packaging: the PyPI package stays aiomsg, just built from python-lib/.
Release tooling (RELEASING.md) updated for the new path.
The thing that proves "native, idiomatic, but interoperable" is true.
Design: thin test agents + a language-agnostic runner.
- Each implementation ships a tiny CLI test agent exposing a uniform
control surface (flags or stdin commands):
--role bind|connect,--send-mode,--delivery-guarantee,--identity,--count, plus behaviours likeecho,sink,source,relay. The agent prints received messages (or a structured summary) to stdout. - The runner (Python
pytest, reusing the existing test ergonomics) spawns pairs/groups of agents — e.g. Rust-async bind ↔ Python connect — drives a scenario, and asserts on the observed messages. - Scenario matrix covers every cell of: {languages} × {bind/connect roles} × {PUBLISH, ROUNDROBIN, identity} × {AT_MOST_ONCE, AT_LEAST_ONCE} × {TLS on/off}, plus reconnection and buffering scenarios (kill & restart a peer).
- Reuse the existing Python tests (
test_hello,test_identity,test_many_connect, the intermittent-server reliability test) as the seed scenarios — they already encode the expected behaviours.
This suite is built incrementally: it comes online the moment Rust-async can talk to Python (proving the spec), and each new language plugs into the same runner.
| Phase | Deliverable | Exit criterion | Status |
|---|---|---|---|
| 0. Plan | This DESIGN.md |
Approved by you | ✅ done |
| 1. Protocol spec | PROTOCOL.md extracted + finalized from §5 |
Reviewed; ambiguity resolved | ✅ done |
| 2. Python relocation | Python under python-lib/, CI green |
Existing tests pass unchanged | ✅ done |
| 3. Python → v1 wire | Typed-envelope change applied to Python ref | Tests pass against new format | ✅ done |
| 4. Rust async | rust-lib-async implementing full spec |
Unit + integration tests pass | ✅ done |
| 5. Conformance harness | conformance/ runner + Rust↔Python scenarios |
Rust-async interoperates with Python | ✅ done |
| 6. Rust sync | rust-lib-sync (independent crate) |
Conformance scenarios pass | ✅ done |
| 7. Go | golang-lib |
Conformance scenarios pass | ✅ done |
| 8. Polish | Per-language docs, examples, CI matrix, releases | All langs documented + published | ◑ publishing remains |
TLS is implemented in all four languages and exercised by the conformance suite
(rustls/ring for both Rust crates, crypto/tls for Go, OpenSSL-backed ssl for
Python). The protocol is identical with or without it. The remaining Phase 8
work is package publishing (crates.io for the two Rust crates, Go module
tagging, PyPI for Python).
Phases 4→7 each follow the same internal rhythm: framing/parsing → handshake + heartbeat → multiplex + send modes → reconnect + buffering → delivery guarantee → TLS → conformance.
Resolved:
receiver_channel— dropped from the canonical API (was unused).AT_LEAST_ONCE+PUBLISH— unsupported in v1 (it makes no sense to wait for acks from a fan-out broadcast).- Shared Rust core — rejected. The two Rust crates are fully independent; the protocol is simple enough to reimplement in each.
rust-lib-syncas ablock_onwrapper overrust-lib-async— rejected. It would erase the sync crate's outbound poll latency (a reactor wakes precisely on readiness) and cut code, but it pulls a tokio runtime into a crate whose entire reason to exist is to be runtime-free, maximises coupling (the opposite of decision 3), addsblock_onreentrancy hazards, and makes the sync↔async conformance test near-tautological. Kept independent and threaded.- Sync outbound poll latency — resolved. The original
rust-lib-syncdrove each connection with one polling thread (≤50 ms idle latency). Two strategies were prototyped to remove it (the "separate socket I/O from TLS state" split, and a per-connection epoll reactor). The split approach won — zero latency, no new deps, contained inconn.rs— and is now therust-lib-syncimplementation (reader + writer threads, rustlsConnectionbehind a short-held mutex). The epoll-reactor approach was dropped as "async-in-disguise" with no edge over the split at per-connection scope; for high connection counts userust-lib-async. Full write-up:rust-lib-sync/docs/threading-and-tls.md. - Callback receive API for the sync crate — rejected. Kept
recv()/messages()for consistency with the rest of the family and the reference's "no callback handlers" ergonomic; avoids RustFn + Sendcapture and broker re-entrancy sharp edges.
Open / proposed:
7. Protocol version policy — HELLO carries a version byte; proposal is
strict reject-on-mismatch for v1.
8. Heartbeat tunability across languages — proposal: fixed 5s/15s defaults,
locally overridable, not negotiated in HELLO for v1.
9. Reliability persistence — out of scope for v1 (matches reference); noted
as a future protocol extension.
On approval of this document:
- Extract §5 into
PROTOCOL.mdand finalize it (Phase 1). - Begin Phase 2 (Python relocation) as a clean, mechanical commit.
Nothing in Phases 1+ is started until this plan is signed off.