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.
This commit is contained in:
parent
8c9311f18a
commit
776efaead3
5 changed files with 501 additions and 9 deletions
283
tests/test_channel.py
Normal file
283
tests/test_channel.py
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue