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.
93 lines
3.8 KiB
Markdown
93 lines
3.8 KiB
Markdown
# 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
|
|
|
|
- Wrap the post-EPMD socket in TLS
|
|
- Match `inet_tls_dist` config on the Erlang side (cert + key + ca paths)
|
|
- Same handshake, just runs inside the TLS tunnel
|
|
|
|
## Phase 7 — unfeed integration
|
|
|
|
- `ErlangDistTransport` implementing unfeed's `WalletTransport` Protocol
|
|
- Maps `/v1/health`, `/v1/address`, ... to gen_call requests
|
|
- `unfeed` deploys with a config switch: `wallet_transport = http | erldist`
|
|
- Cutover after a soak period on staging
|