erldistpy/tests/test_channel.py
russell@unturf.com 776efaead3
phase 4: distribution data channel
Channel wraps the post-handshake socket and carries 4-byte length-
prefixed distribution messages: pass-through byte ('p') + ETF control
tuple + optional payload term.

API surface:
  send_raw / recv_raw     -- raw 4-byte framed bytes, empty == net_tick
  send_tick               -- send keepalive frame
  send_control / recv_message  -- structured control + payload
  send_reg_send           -- helper for the REG_SEND case (FromPid,
                             registered name, payload)

recv_message() transparently skips inbound ticks; callers wanting tick
awareness use recv_raw().

etf.decode_term(data, offset) exposed as a streaming decoder so the
channel can read control + payload back-to-back from one frame body.

Tests:
  - pure encode/decode round-trips
  - socketpair tests for framing, ticks, helper signatures
  - live end-to-end against an erl node with a registered echo process:
    EPMD -> handshake -> REG_SEND -> recv reply, payload matches
  - boot script writes a /tmp ready-flag after registering `echo`;
    fixture waits for both EPMD registration AND the flag to dodge
    the race where EPMD registers the node before -eval runs

101 tests green, lint clean.
2026-06-16 11:23:16 -04:00

283 lines
8.7 KiB
Python

"""Distribution channel tests.
Unit tests cover the encode/decode of pass-through messages.
Live tests spawn an Erlang node with a registered echo process,
complete the full EPMD lookup + handshake + REG_SEND + recv cycle,
and verify the round-tripped payload matches.
"""
from __future__ import annotations
import shutil
import socket
import struct
import subprocess
import time
import pytest
from erldistpy.channel import (
OP_REG_SEND,
OP_SEND,
OP_SEND_SENDER,
PASS_THROUGH,
Channel,
ChannelError,
IncomingMessage,
_decode_message,
_encode_message,
)
from erldistpy.epmd import lookup
from erldistpy.etf import Atom, Pid, encode
from erldistpy.handshake import handshake
# --------------------------------------------------------------------------
# Pure encode/decode
# --------------------------------------------------------------------------
def test_encode_message_control_only():
"""LINK/UNLINK/etc. have no payload — just a control tuple."""
control = (1, Pid(Atom("a@b"), 0, 0, 1), Pid(Atom("c@d"), 0, 0, 1))
body = _encode_message(control, None)
assert body[0] == PASS_THROUGH
# Decode round-trip
msg = _decode_message(body)
assert msg.control == control
assert msg.payload is None
def test_encode_message_with_payload():
control = (OP_REG_SEND, Pid(Atom("client@h"), 1, 0, 5), Atom(""), Atom("echo"))
payload = (Atom("hello"), b"world")
body = _encode_message(control, payload)
msg = _decode_message(body)
assert msg.control == control
assert msg.payload == payload
def test_decode_rejects_wrong_first_byte():
with pytest.raises(ChannelError, match="pass-through byte"):
_decode_message(b"\x00" + encode((1,)))
def test_decode_rejects_non_tuple_control():
body = bytes([PASS_THROUGH]) + encode(42)
with pytest.raises(ChannelError, match="not a tuple"):
_decode_message(body)
def test_decode_rejects_trailing_garbage():
body = bytes([PASS_THROUGH]) + encode((1,)) + b"\xFF"
with pytest.raises(ChannelError):
_decode_message(body)
def test_incoming_message_op_property():
msg = IncomingMessage(control=(OP_SEND, Atom(""), Pid(Atom("x@y"), 0, 0, 1)), payload=42)
assert msg.op == OP_SEND
# --------------------------------------------------------------------------
# Raw socket framing (loopback with a paired socket)
# --------------------------------------------------------------------------
def _socket_pair() -> tuple[socket.socket, socket.socket]:
return socket.socketpair()
def test_send_raw_recv_raw_round_trip():
a, b = _socket_pair()
ch_a = Channel(a)
ch_b = Channel(b)
ch_a.send_raw(b"hello")
assert ch_b.recv_raw() == b"hello"
def test_tick_round_trip():
a, b = _socket_pair()
ch_a = Channel(a)
ch_b = Channel(b)
ch_a.send_tick()
assert ch_b.recv_raw() == b""
def test_send_control_recv_message():
a, b = _socket_pair()
ch_a = Channel(a)
ch_b = Channel(b)
control = (OP_REG_SEND, Pid(Atom("a@h"), 1, 0, 7), Atom(""), Atom("echo"))
payload = (Atom("greet"), b"hi")
ch_a.send_control(control, payload)
msg = ch_b.recv_message()
assert msg is not None
assert msg.control == control
assert msg.payload == payload
def test_recv_message_skips_ticks():
a, b = _socket_pair()
ch_a = Channel(a)
ch_b = Channel(b)
ch_a.send_tick()
ch_a.send_tick()
control = (OP_SEND, Atom(""), Pid(Atom("x@y"), 0, 0, 1))
ch_a.send_control(control, b"payload")
msg = ch_b.recv_message()
assert msg is not None
assert msg.control == control
assert msg.payload == b"payload"
def test_send_reg_send_helper():
a, b = _socket_pair()
ch_a = Channel(a)
ch_b = Channel(b)
from_pid = Pid(Atom("client@h"), 42, 0, 9)
ch_a.send_reg_send(from_pid, Atom("kernel"), (Atom("ping"), 1))
msg = ch_b.recv_message()
assert msg is not None
assert msg.control == (OP_REG_SEND, from_pid, Atom(""), Atom("kernel"))
assert msg.payload == (Atom("ping"), 1)
def test_recv_after_peer_closes_raises():
a, b = _socket_pair()
ch_a = Channel(a)
Channel(b).close()
with pytest.raises(ChannelError, match="peer closed"):
# The 4-byte length header will fail because peer closed
ch_a.recv_raw()
def test_recv_handles_truncated_length_header():
"""If only 2 of 4 length bytes arrive then peer dies, we must error
instead of hanging or returning garbage."""
a, b = _socket_pair()
a.sendall(b"\x00\x00") # only 2 of 4 header bytes
a.close()
ch_b = Channel(b)
with pytest.raises(ChannelError, match="peer closed"):
ch_b.recv_raw()
# --------------------------------------------------------------------------
# Live end-to-end — EPMD + handshake + REG_SEND + recv reply
# --------------------------------------------------------------------------
# Spawn an erl node with a tiny echo process registered as `echo`.
# The Y-combinator trick lets us write a recursive fun inside -eval.
# A flag file is written after registration so the fixture can wait for
# the process to actually be reachable (EPMD registers the node *before*
# our -eval runs, so polling EPMD alone is racy).
READY_FLAG = "/tmp/erldistpy_test_channel_ready"
ERL_ECHO_BOOT = (
"EchoLoop = fun(F) -> "
"receive {From, Msg} -> From ! {echoed, Msg}, F(F) end "
"end, "
"Pid = spawn(fun() -> EchoLoop(EchoLoop) end), "
"register(echo, Pid), "
f'file:write_file("{READY_FLAG}", "1"), '
"timer:sleep(infinity)."
)
def _epmd_running() -> bool:
try:
with socket.create_connection(("localhost", 4369), timeout=0.5):
return True
except OSError:
return False
@pytest.fixture(scope="module")
def echo_node():
if not shutil.which("erl"):
pytest.skip("erl not installed")
if not _epmd_running():
pytest.skip("EPMD not running on localhost")
import os
# Clear stale flag from a previous run
if os.path.exists(READY_FLAG):
os.remove(READY_FLAG)
sname = "erldistpy_echo"
cookie = "ERLDISTPY_CH_COOKIE"
proc = subprocess.Popen(
["erl", "-sname", sname, "-setcookie", cookie, "-noshell", "-eval", ERL_ECHO_BOOT],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# Wait for BOTH: node registered with EPMD AND echo process up
deadline = time.monotonic() + 6.0
ready = False
while time.monotonic() < deadline:
time.sleep(0.1)
if lookup(sname, timeout=0.5) is None:
continue
if os.path.exists(READY_FLAG):
ready = True
break
if not ready:
proc.terminate()
proc.wait(timeout=2)
pytest.skip("erl echo process did not become ready in time")
yield sname, cookie
proc.terminate()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()
if os.path.exists(READY_FLAG):
os.remove(READY_FLAG)
def test_live_reg_send_echo(echo_node):
sname, cookie = echo_node
info = lookup(sname)
assert info is not None
sock = socket.create_connection(("localhost", info.port), timeout=2)
try:
client_name = f"erldistpy_ch@{socket.gethostname()}"
result = handshake(sock, our_name=client_name, cookie=cookie)
ch = Channel(sock, recv_timeout=3.0)
from_pid = Pid(Atom(client_name), 1, 0, result.our_creation)
message_body = b"hello over native dist"
ch.send_reg_send(from_pid, Atom("echo"), (from_pid, message_body))
msg = ch.recv_message()
assert msg is not None
# Peer may use OP_SEND (legacy) or OP_SEND_SENDER (if it offered DFLAG_SEND_SENDER)
assert msg.op in (OP_SEND, OP_SEND_SENDER)
# Payload echoes our tuple form
assert msg.payload == (Atom("echoed"), message_body)
finally:
sock.close()
def test_live_reg_send_survives_tick(echo_node):
"""An inbound tick frame before the reply must not break recv_message()."""
sname, cookie = echo_node
info = lookup(sname)
assert info is not None
sock = socket.create_connection(("localhost", info.port), timeout=2)
try:
client_name = f"erldistpy_ch_tick@{socket.gethostname()}"
result = handshake(sock, our_name=client_name, cookie=cookie)
ch = Channel(sock, recv_timeout=3.0)
ch.send_tick() # provoke nothing in particular — just exercise the wire
from_pid = Pid(Atom(client_name), 2, 0, result.our_creation)
ch.send_reg_send(from_pid, Atom("echo"), (from_pid, b"after tick"))
msg = ch.recv_message()
assert msg is not None
assert msg.payload == (Atom("echoed"), b"after tick")
finally:
sock.close()
# Mirror struct import only used implicitly by ETF; keep linters happy.
_ = struct