# erldistpy roadmap Phases below are sized to land as discrete commits. Each phase ends with `make all` green and a real-VM interop test where applicable. ## Phase 0 — Repo bones ✅ - LICENSE, README, .gitignore, Makefile, pyproject.toml - Package skeleton, ruff config matches unfeed - `make bootstrap test lint` works on a fresh checkout ## Phase 1 — ETF codec ✅ - Subset of External Term Format we need for gen_call to Elixir: small_int, int, big_int, atom_utf8 (legacy atom_ext on decode), binary, nil, list, tuple (small + large), pid (new_pid), ref (newer_reference) - Booleans and `None` ride as atoms `true` / `false` / `nil` - Golden vectors decoded from real `term_to_binary/1` output - Round-trip tests for the encoder - Generator script committed at `docs/etf_vectors.erl` ## Phase 2 — EPMD client ✅ EPMD (Erlang Port Mapper Daemon) maps node names to TCP ports. - Synchronous TCP client - `PORT_PLEASE2_REQ` (122) → `EpmdInfo(name, port, node_type, protocol, hi_ver, lo_ver, extra)` - Returns `None` cleanly on unregistered node, raises `EpmdError` on socket failures - Tests: recorded byte streams from real EPMD + live integration that spawns its own `erl -sname` and tears it down ## Phase 3 — Distribution handshake ✅ - `handshake(sock, our_name=..., cookie=...)` drives the v6 dance: send_name(N) / recv_status(s) / recv_challenge(N) / send_challenge_reply(r) / recv_challenge_ack(a) - MD5 cookie digest, cross-checked against `erlang:md5/1` output - Flag set in `erldistpy/flags.py` advertises OTP 23+ compatibility - Tests: - Frame builders/parsers as pure functions - Cookie digest against an Erlang-computed reference - Live handshake against `erl -sname ... -setcookie ...` - Wrong-cookie test confirms peer rejection surfaces correctly Newer SHA-256 digest (DFLAG_MANDATORY_25_DIGEST) deferred — landed only if we hit a peer that requires it. ## Phase 4 — Distribution channel ✅ - `Channel` wraps the post-handshake socket: 4-byte length frames, pass-through byte (`'p'`), control tuple + optional payload term - `send_reg_send(from_pid, to_name, payload)` helper for the common case - `recv_message()` skips ticks transparently; `recv_raw()` exposes them for callers that need tick awareness - Tick keepalive via `send_tick()` — caller drives the timer for now (background ticker lands in Phase 7 alongside unfeed integration) - Tests: - Pure encode/decode round-trips - Socket-pair tests for send/recv framing, tick handling - Live end-to-end against an `erl` node with a registered echo process: EPMD → handshake → REG_SEND → recv reply, payload matches - Survives an outbound tick before the reply ## Phase 5 — gen_call convenience layer ✅ - `Node(our_name, peer_name, cookie)` lazy-connects: EPMD lookup + TCP connect + v6 handshake on construction - `Node.call(target_name, request, timeout=5.0)` implements the `$gen_call` protocol: ``` caller -> {'$gen_call', {FromPid, Ref}, Request} (REG_SEND) server -> {Ref, Reply} (SEND) ``` - Unique Ref per call, matched on receive — protocol violations surface as `CallProtocolError`, timeouts as `CallTimeout` - Tests: `{ping, X} -> {pong, X}`, `{add, A, B} -> {ok, A+B}`, sequential calls without crosstalk, timeout against a slow responder, drop-on-the-floor against an unknown registered name, Ref uniqueness across the Node lifetime ## Phase 6 — TLS dist ✅ - `make_dist_tls_context(cert=, key=, ca=)` helper builds an `ssl.SSLContext` tuned for OTP defaults (verify_peer, TLSv1.2 min) - `Node(..., tls_context=ctx)` wraps the post-EPMD socket in TLS before the dist handshake - Auto-selects `frame_size=4` for the handshake when TLS is in use (the non-obvious quirk: `inet_tls_dist` uses `{packet, 4}` on the SSL socket, where plain `inet_tcp_dist` uses `{packet, 2}` during handshake) - Live tests: generate CA + SAN-bearing certs with openssl, spawn `erl -proto_dist inet_tls`, drive a full `Node.call()` through the tunnel. Negative tests confirm: plaintext connection fails, wrong-CA client cert fails. ## Phase 7 — unfeed integration ✅ - `ErlangDistTransport` lives in `unfeed/wallet.py` (not erldistpy itself — keeps erldistpy app-agnostic) - Implements `WalletTransport` Protocol via `erldistpy.Node.call` - Wraps each HTTP-shape `(method, path, json, params)` into a single `{:unfeed_rpc, Method, Path, JsonBody, Params}` gen_server request - Reply translation: atoms/binaries → str, nested maps/lists recurse - Error mapping: `{:error, code, msg, retryable}` → `WalletBridgeError`; `CallTimeout` → `bridge_timeout`; `NodeError` → `bridge_unreachable` - Selected per-deploy via `unfeed.wallet_transport = http | erldist` - Optional TLS dist by setting `wallet_node_tls_*` config keys - 11 tests in unfeed/tests/test_wallet_erldist.py mock `Node.call` and verify the full request/reply contract The Elixir-side gen_server (`wallet_rpc`) doesn't exist yet — it's the next ticket on the Elixir wallet bridge side. Once it lands and a staging deploy stays healthy, flip the production switch.