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.
203 lines
6.5 KiB
Python
203 lines
6.5 KiB
Python
"""High-level Node tests.
|
|
|
|
Pure tests cover ref/pid generation. Live tests spawn an erl node with
|
|
a process that implements the gen_server reply protocol by hand
|
|
(no module compilation needed, just a receive on $gen_call) and
|
|
exercise Node.call() end-to-end.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from erldistpy.etf import Atom, Reference
|
|
from erldistpy.node import CallProtocolError, CallTimeout, Node, NodeError
|
|
|
|
# Boot script registers `gen_target` which mimics gen_server's reply
|
|
# protocol. Two operations supported:
|
|
# {ping, X} -> {pong, X}
|
|
# {add, A, B} -> {ok, A + B}
|
|
# slow -> sleeps 2s before replying — used to force timeout
|
|
#
|
|
# The Y-combinator trick lets the receive loop be expressed inline.
|
|
READY_FLAG = "/tmp/erldistpy_test_node_ready"
|
|
ERL_GEN_BOOT = (
|
|
"Handle = fun "
|
|
" ({ping, X}) -> {pong, X}; "
|
|
" ({add, A, B}) -> {ok, A + B}; "
|
|
" (slow) -> timer:sleep(2000), {ok, slow}; "
|
|
" (Other) -> {error, {bad_request, Other}} "
|
|
"end, "
|
|
"Loop = fun(F) -> "
|
|
" receive "
|
|
" {'$gen_call', {From, Ref}, Request} -> "
|
|
" From ! {Ref, Handle(Request)}, F(F); "
|
|
" Other -> "
|
|
" io:format(\"unexpected: ~p~n\", [Other]), F(F) "
|
|
" end "
|
|
"end, "
|
|
"Pid = spawn(fun() -> Loop(Loop) end), "
|
|
"register(gen_target, 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 gen_peer():
|
|
if not shutil.which("erl"):
|
|
pytest.skip("erl not installed")
|
|
if not _epmd_running():
|
|
pytest.skip("EPMD not running on localhost")
|
|
|
|
if os.path.exists(READY_FLAG):
|
|
os.remove(READY_FLAG)
|
|
|
|
sname = "erldistpy_gen"
|
|
cookie = "ERLDISTPY_GEN_COOKIE"
|
|
proc = subprocess.Popen(
|
|
["erl", "-sname", sname, "-setcookie", cookie, "-noshell", "-eval", ERL_GEN_BOOT],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
|
|
deadline = time.monotonic() + 6.0
|
|
from erldistpy.epmd import lookup
|
|
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 gen_target did not become ready")
|
|
|
|
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)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Pure validation
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_node_requires_qualified_our_name():
|
|
with pytest.raises(ValueError, match="name@host"):
|
|
Node(our_name="bare", peer_name="x", cookie="X")
|
|
|
|
|
|
def test_node_unknown_peer_raises():
|
|
with pytest.raises(NodeError, match="not registered"):
|
|
Node(
|
|
our_name="erldistpy_test@localhost",
|
|
peer_name="definitely_not_a_real_node_qzxw",
|
|
cookie="X",
|
|
connect_timeout=1.0,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Live call() against a process implementing $gen_call
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def _client_name() -> str:
|
|
return f"erldistpy_node@{socket.gethostname()}"
|
|
|
|
|
|
def test_call_ping(gen_peer):
|
|
sname, cookie = gen_peer
|
|
with Node(our_name=_client_name(), peer_name=sname, cookie=cookie) as node:
|
|
reply = node.call("gen_target", (Atom("ping"), 42))
|
|
assert reply == (Atom("pong"), 42)
|
|
|
|
|
|
def test_call_add(gen_peer):
|
|
sname, cookie = gen_peer
|
|
with Node(our_name=_client_name(), peer_name=sname, cookie=cookie) as node:
|
|
reply = node.call("gen_target", (Atom("add"), 7, 35))
|
|
assert reply == (Atom("ok"), 42)
|
|
|
|
|
|
def test_call_multiple_in_sequence(gen_peer):
|
|
"""Each call must increment Ref and not crosstalk with prior calls."""
|
|
sname, cookie = gen_peer
|
|
with Node(our_name=_client_name(), peer_name=sname, cookie=cookie) as node:
|
|
for i in range(5):
|
|
reply = node.call("gen_target", (Atom("add"), i, 1))
|
|
assert reply == (Atom("ok"), i + 1)
|
|
|
|
|
|
def test_call_bad_request_surfaces_server_error(gen_peer):
|
|
sname, cookie = gen_peer
|
|
with Node(our_name=_client_name(), peer_name=sname, cookie=cookie) as node:
|
|
reply = node.call("gen_target", Atom("nonsense"))
|
|
# Server returns {error, {bad_request, Other}} per our handle fun
|
|
assert isinstance(reply, tuple)
|
|
assert reply[0] == Atom("error")
|
|
|
|
|
|
def test_call_timeout(gen_peer):
|
|
"""Server sleeps 2s; our timeout is 0.3s — must raise CallTimeout."""
|
|
sname, cookie = gen_peer
|
|
with (
|
|
Node(our_name=_client_name(), peer_name=sname, cookie=cookie) as node,
|
|
pytest.raises(CallTimeout),
|
|
):
|
|
node.call("gen_target", Atom("slow"), timeout=0.3)
|
|
|
|
|
|
def test_call_unknown_registered_name_times_out(gen_peer):
|
|
"""Sending to a non-existent registered name is silently dropped by
|
|
Erlang — we must surface this as a timeout, not hang forever."""
|
|
sname, cookie = gen_peer
|
|
with (
|
|
Node(our_name=_client_name(), peer_name=sname, cookie=cookie) as node,
|
|
pytest.raises(CallTimeout),
|
|
):
|
|
node.call("does_not_exist", (Atom("ping"), 1), timeout=0.4)
|
|
|
|
|
|
def test_ref_counter_is_per_node_lifetime():
|
|
"""Synthesized references must be unique within a Node instance."""
|
|
# We don't need a live peer; just exercise _next_ref via a dummy bypass.
|
|
# Construct a Node without going through __init__ to avoid the network.
|
|
node = Node.__new__(Node)
|
|
node._our_atom = Atom("x@y")
|
|
node.our_creation = 1
|
|
node._ref_counter = 0
|
|
refs = [node._next_ref() for _ in range(100)]
|
|
assert len(set((r.creation, r.ids) for r in refs)) == 100
|
|
assert all(isinstance(r, Reference) for r in refs)
|
|
|
|
|
|
def test_protocol_error_path_uses_callprotocolerror():
|
|
"""CallProtocolError exists as a distinct exception so callers can
|
|
distinguish 'server misbehaved' from 'server timed out'."""
|
|
assert issubclass(CallTimeout, NodeError)
|
|
assert issubclass(CallProtocolError, NodeError)
|