Node wraps EPMD lookup + handshake + Channel into a single client
object. Constructor eagerly opens the dist connection; call() runs the
synchronous $gen_call protocol against a registered name on the peer:
caller -> {'$gen_call', {FromPid, Ref}, Request} (REG_SEND)
server -> {Ref, Reply} (SEND)
Synthesized FromPid and a Node-lifetime Ref counter route replies back
to us; mismatched Ref or unexpected control op raises CallProtocolError.
Reply timeout raises CallTimeout (also covers Erlang's silent-drop case
when the registered name doesn't exist).
Tests against an erl peer running a $gen_call-aware loop:
- {ping, X} -> {pong, X}
- {add, A, B} -> {ok, A + B}
- five sequential calls with monotonically increasing Refs
- server error response surfaces as Python tuple
- slow responder triggers CallTimeout
- unknown registered name surfaces as CallTimeout
- ref uniqueness across 100 synthesized refs
111 tests green across 10 consecutive runs, lint clean.
3.8 KiB
3.8 KiB
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 lintworks 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
Noneride as atomstrue/false/nil - Golden vectors decoded from real
term_to_binary/1output - 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
Nonecleanly on unregistered node, raisesEpmdErroron socket failures - Tests: recorded byte streams from real EPMD + live integration that
spawns its own
erl -snameand 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/1output - Flag set in
erldistpy/flags.pyadvertises 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 ✅
Channelwraps the post-handshake socket: 4-byte length frames, pass-through byte ('p'), control tuple + optional payload termsend_reg_send(from_pid, to_name, payload)helper for the common caserecv_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
erlnode 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 constructionNode.call(target_name, request, timeout=5.0)implements the$gen_callprotocol: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 asCallTimeout - 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
- Wrap the post-EPMD socket in TLS
- Match
inet_tls_distconfig on the Erlang side (cert + key + ca paths) - Same handshake, just runs inside the TLS tunnel
Phase 7 — unfeed integration
ErlangDistTransportimplementing unfeed'sWalletTransportProtocol- Maps
/v1/health,/v1/address, ... to gen_call requests unfeeddeploys with a config switch:wallet_transport = http | erldist- Cutover after a soak period on staging