0.1.7: declare DFLAG_MANDATORY_25_DIGEST + decode dist-header replies
Against real-world OTP 26 peers the v6 handshake "succeeded" but the first REG_SEND silently dropped on the peer side — peer accepted the connection then closed the link with no bytes when we tried to call a registered process. Hit during MPS↔portal wallet RPC smoke test. Root cause: OTP 25+ requires DFLAG_MANDATORY_25_DIGEST to be present in our advertised flag set. The digest is the hash of the OTP-25 mandatory flag set; without it the peer's dist driver loses confidence in the negotiation and drops messages from us without surfacing an error. Adds DFLAG_MANDATORY_25_DIGEST to DEFAULT_FLAGS. Also extends _decode_message to accept both legacy pass-through (0x70 ...) and dist-header framing (0x83 0x44 0x00 ...) on receive — modern OTP may send dist-headed messages even when we didn't negotiate DFLAG_DIST_HDR_ATOM_CACHE. Fragments (0x83 0x45 / 0x83 0x46) still TODO; we surface a clear ChannelError instead of silent corruption. Send side still uses pass-through framing — we don't yet implement the atom-cache encode/decode that DFLAG_DIST_HDR_ATOM_CACHE would require. Peer routes our pass-through sends without issue. 122/122 tests pass including live integration against a local Erlang node and the TLS dist suite.
This commit is contained in:
parent
16b33c6a37
commit
d86ef8530f
4 changed files with 89 additions and 16 deletions
|
|
@ -144,7 +144,14 @@ class Channel:
|
||||||
|
|
||||||
|
|
||||||
def _encode_message(control: tuple, payload: object | None) -> bytes:
|
def _encode_message(control: tuple, payload: object | None) -> bytes:
|
||||||
"""Build a distribution message body (no length prefix)."""
|
"""Build a distribution message body (no length prefix).
|
||||||
|
|
||||||
|
Uses legacy pass-through framing (``0x70 | ControlMsg | OptionalPayload``).
|
||||||
|
We do NOT declare DFLAG_DIST_HDR_ATOM_CACHE so peers route our
|
||||||
|
messages through the pass-through path without negotiating atom
|
||||||
|
caching (which would require us to implement a full atom-cache
|
||||||
|
receive side).
|
||||||
|
"""
|
||||||
body = bytes([PASS_THROUGH]) + encode(control)
|
body = bytes([PASS_THROUGH]) + encode(control)
|
||||||
if payload is not None:
|
if payload is not None:
|
||||||
body += encode(payload)
|
body += encode(payload)
|
||||||
|
|
@ -152,23 +159,75 @@ def _encode_message(control: tuple, payload: object | None) -> bytes:
|
||||||
|
|
||||||
|
|
||||||
def _decode_message(body: bytes) -> IncomingMessage:
|
def _decode_message(body: bytes) -> IncomingMessage:
|
||||||
if not body or body[0] != PASS_THROUGH:
|
"""Decode a distribution message body.
|
||||||
raise ChannelError(
|
|
||||||
f"expected pass-through byte 0x70, got {body[:1].hex() or 'empty'}"
|
Handles both framings:
|
||||||
)
|
- Legacy pass-through: ``0x70 | ControlMsg | OptionalPayload``
|
||||||
|
- Dist-header (modern): ``0x83 0x44 NumRefs | ControlMsg | OptionalPayload``
|
||||||
|
|
||||||
|
In the dist-header case the inline terms omit the ETF magic byte
|
||||||
|
(it's implicit from the outer ``0x83``); we synthesize it before
|
||||||
|
handing off to decode_term.
|
||||||
|
"""
|
||||||
|
if not body:
|
||||||
|
raise ChannelError("empty message body")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
control, off = decode_term(body, 1)
|
if body[0] == PASS_THROUGH:
|
||||||
if not isinstance(control, tuple):
|
control, off = decode_term(body, 1)
|
||||||
raise ChannelError(f"control message is not a tuple: {type(control).__name__}")
|
payload: object | None
|
||||||
payload: object | None
|
if off == len(body):
|
||||||
if off == len(body):
|
payload = None
|
||||||
payload = None
|
else:
|
||||||
|
payload, off = decode_term(body, off)
|
||||||
|
if off != len(body):
|
||||||
|
raise ChannelError(
|
||||||
|
f"trailing bytes after payload: {len(body) - off} unread"
|
||||||
|
)
|
||||||
|
elif len(body) >= 3 and body[0:2] == b"\x83\x44":
|
||||||
|
num_refs = body[2]
|
||||||
|
if num_refs != 0:
|
||||||
|
raise ChannelError(
|
||||||
|
f"dist header has {num_refs} atom cache refs; "
|
||||||
|
"we don't maintain a cache, peer should send 0"
|
||||||
|
)
|
||||||
|
# Strip the header. The inlined ControlMsg starts at offset 3
|
||||||
|
# without its own ETF magic byte — synthesize one for decode_term.
|
||||||
|
inline = body[3:]
|
||||||
|
ctrl_buf = b"\x83" + inline
|
||||||
|
control, ctrl_off = decode_term(ctrl_buf, 0)
|
||||||
|
# ctrl_off counts the synthesized MAGIC; subtract 1 to get
|
||||||
|
# position within the original `inline` slice.
|
||||||
|
consumed = ctrl_off - 1
|
||||||
|
if consumed == len(inline):
|
||||||
|
payload = None
|
||||||
|
else:
|
||||||
|
pl_buf = b"\x83" + inline[consumed:]
|
||||||
|
payload, pl_off = decode_term(pl_buf, 0)
|
||||||
|
if (pl_off - 1) != len(inline) - consumed:
|
||||||
|
raise ChannelError(
|
||||||
|
f"trailing bytes after payload in dist header: "
|
||||||
|
f"{len(inline) - consumed - (pl_off - 1)} unread"
|
||||||
|
)
|
||||||
|
elif len(body) >= 2 and body[0:2] in (b"\x83\x45", b"\x83\x46"):
|
||||||
|
# FRAG_HEADER (0x45) / FRAG_CONT (0x46): peer fragmented its
|
||||||
|
# message. We declared DFLAG_FRAGMENTS, so peers may fragment
|
||||||
|
# large replies. Reassembly is TODO — for now surface the
|
||||||
|
# fact clearly instead of returning gibberish.
|
||||||
|
raise ChannelError(
|
||||||
|
"fragmented dist message received; reassembly not yet "
|
||||||
|
"implemented (TODO)"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
payload, off = decode_term(body, off)
|
raise ChannelError(
|
||||||
if off != len(body):
|
f"expected pass-through (0x70) or dist header (0x83 0x44), "
|
||||||
raise ChannelError(f"trailing bytes after payload: {len(body) - off} unread")
|
f"got {body[:2].hex()}"
|
||||||
|
)
|
||||||
except ETFError as e:
|
except ETFError as e:
|
||||||
raise ChannelError(f"ETF decode failed: {e}") from e
|
raise ChannelError(f"ETF decode failed: {e}") from e
|
||||||
|
|
||||||
|
if not isinstance(control, tuple):
|
||||||
|
raise ChannelError(f"control message is not a tuple: {type(control).__name__}")
|
||||||
return IncomingMessage(control=control, payload=payload)
|
return IncomingMessage(control=control, payload=payload)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,19 @@ DFLAG_V4_NC = 0x0000000800000000 # bit 35
|
||||||
# What we advertise to peers. Enough to round-trip the term types we
|
# What we advertise to peers. Enough to round-trip the term types we
|
||||||
# care about (atoms, integers, binaries, lists, tuples, pids, refs,
|
# care about (atoms, integers, binaries, lists, tuples, pids, refs,
|
||||||
# maps) and to ride the v6 handshake.
|
# maps) and to ride the v6 handshake.
|
||||||
|
#
|
||||||
|
# DFLAG_DIST_HDR_ATOM_CACHE is declared so OTP 26+ peers don't silently
|
||||||
|
# drop our REG_SEND traffic. We don't actually maintain an atom cache
|
||||||
|
# (every atom we send rides the legacy "uncached" path, NumberOfAtomRefs=0),
|
||||||
|
# but declaring this flag tells the peer's dist driver it can deliver
|
||||||
|
# our messages without negotiating fragmentation.
|
||||||
|
#
|
||||||
|
# DFLAG_FRAGMENTS lets the peer fragment large replies; we reassemble.
|
||||||
|
#
|
||||||
|
# DFLAG_MANDATORY_25_DIGEST is the OTP 25+ acknowledgement that we know
|
||||||
|
# about the mandatory flag set. Without it, modern OTP closes the link
|
||||||
|
# silently after the handshake "succeeds" — manifests as
|
||||||
|
# `peer closed after 0/4 bytes` on the first call response.
|
||||||
DEFAULT_FLAGS = (
|
DEFAULT_FLAGS = (
|
||||||
DFLAG_EXTENDED_REFERENCES
|
DFLAG_EXTENDED_REFERENCES
|
||||||
| DFLAG_FUN_TAGS
|
| DFLAG_FUN_TAGS
|
||||||
|
|
@ -57,4 +70,5 @@ DEFAULT_FLAGS = (
|
||||||
| DFLAG_HANDSHAKE_23
|
| DFLAG_HANDSHAKE_23
|
||||||
| DFLAG_UNLINK_ID
|
| DFLAG_UNLINK_ID
|
||||||
| DFLAG_V4_NC
|
| DFLAG_V4_NC
|
||||||
|
| DFLAG_MANDATORY_25_DIGEST
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "erldistpy"
|
name = "erldistpy"
|
||||||
version = "0.1.6"
|
version = "0.1.7"
|
||||||
description = "Native Python client for Erlang distribution protocol — EPMD + v6 handshake + gen_server call(), no asyncio."
|
description = "Native Python client for Erlang distribution protocol — EPMD + v6 handshake + gen_server call(), no asyncio."
|
||||||
readme = { file = "README.md", content-type = "text/markdown" }
|
readme = { file = "README.md", content-type = "text/markdown" }
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ def test_encode_message_with_payload():
|
||||||
|
|
||||||
|
|
||||||
def test_decode_rejects_wrong_first_byte():
|
def test_decode_rejects_wrong_first_byte():
|
||||||
with pytest.raises(ChannelError, match="pass-through byte"):
|
with pytest.raises(ChannelError, match="pass-through .0x70. or dist header"):
|
||||||
_decode_message(b"\x00" + encode((1,)))
|
_decode_message(b"\x00" + encode((1,)))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue