From 2952c009a8e1a88e7408f1b3f4ea89b630022c25 Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:53:52 -0400 Subject: [PATCH 1/2] Guard the TCP readback contract with a test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tcp_offset()` and `tcp_transform()` have raised on an unanswered readback since 460e4c7, but nothing asserted it. That commit changed the behaviour while applying full TCP transforms through planning and readback, so the guarantee arrived unannounced and unguarded — one refactor away from silently returning to a zero vector. Zero is a legitimate offset. A caller handed `[0, 0, 0]` as a not-answered sentinel cannot tell "the offset is zero" from "there is no controller", and a host that adopts the readback quietly erases the offset the user just set. waldoctl's `RobotClient` forbids exactly that, and par6 carries the same guarantee behind a test (par6#80); this is the parol6 side of it. The test drives the real client against the simulator and proves the two cases are distinguishable: a deliberate `set_tcp_offset(0, 0, 0)` reads back as the value `[0, 0, 0]`, while both readbacks raise `TimeoutError` against a port nothing is listening on. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TvTCMA9Y9RUaVN33JhHCNQ --- .../integration/test_tcp_readback_contract.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/integration/test_tcp_readback_contract.py diff --git a/tests/integration/test_tcp_readback_contract.py b/tests/integration/test_tcp_readback_contract.py new file mode 100644 index 0000000..155b286 --- /dev/null +++ b/tests/integration/test_tcp_readback_contract.py @@ -0,0 +1,43 @@ +"""The TCP readbacks must not answer for a controller that did not reply. + +``[0, 0, 0]`` is a legitimate offset -- a tool deliberately cleared -- so a +caller handed one as a not-answered sentinel cannot tell "the offset is zero" +from "there is no controller", and a host that adopts the readback quietly +erases the offset the user just set. The same holds for ``tcp_transform`` and +an identity transform. waldoctl's ``RobotClient`` states both in as many words; +par6 carries the guarantee (par6#80) and this is the parol6 side of it. +""" + +import socket +from math import isfinite + +import pytest + +from parol6 import RobotClient + + +def _unserved_udp_port() -> int: + """A port nothing is listening on.""" + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: + probe.bind(("127.0.0.1", 0)) + return int(probe.getsockname()[1]) + + +@pytest.mark.integration +def test_tcp_readbacks_separate_a_real_zero_from_a_silent_controller(client): + """A deliberate zero reads back as a value; an unanswered readback raises.""" + assert client.set_tcp_offset(0.0, 0.0, 0.0) == 1 + + assert [float(v) for v in client.tcp_offset()] == [0.0, 0.0, 0.0] + + transform = [float(v) for v in client.tcp_transform()] + assert len(transform) == 6 + assert all(isfinite(v) for v in transform) + + with RobotClient( + host="127.0.0.1", port=_unserved_udp_port(), timeout=0.2, retries=1 + ) as mute: + with pytest.raises(TimeoutError): + mute.tcp_offset() + with pytest.raises(TimeoutError): + mute.tcp_transform() From 4e74f5c20977ea6e194aed1f3e1f7642be65f9b3 Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:13:37 -0400 Subject: [PATCH 2/2] Drive the TCP offset through the queue the controller puts it in The test asserted `set_tcp_offset` answered 1, the system-command code. It answers a command index: `SET_TCP_OFFSET` sits in `QUEUED_CMD_TYPES` and lands in queue order, not on arrival. Every test job failed on `assert 264 == 1`, and the readback that followed raced the queue it had not waited for. Wait for the index, and set a non-zero offset before clearing it, so the zero the test reads back is one the controller was told to hold rather than the state it happened to start in. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TvTCMA9Y9RUaVN33JhHCNQ --- tests/integration/test_tcp_readback_contract.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_tcp_readback_contract.py b/tests/integration/test_tcp_readback_contract.py index 155b286..7233bb9 100644 --- a/tests/integration/test_tcp_readback_contract.py +++ b/tests/integration/test_tcp_readback_contract.py @@ -23,14 +23,24 @@ def _unserved_udp_port() -> int: return int(probe.getsockname()[1]) +def _apply_offset(client, x: float, y: float, z: float) -> None: + """Set the TCP offset and wait for it: SET_TCP_OFFSET lands in queue order.""" + index = client.set_tcp_offset(x, y, z) + assert index >= 0, f"set_tcp_offset was not accepted (got {index})" + assert client.wait_command(index, timeout=10.0), "set_tcp_offset did not complete" + + @pytest.mark.integration def test_tcp_readbacks_separate_a_real_zero_from_a_silent_controller(client): """A deliberate zero reads back as a value; an unanswered readback raises.""" - assert client.set_tcp_offset(0.0, 0.0, 0.0) == 1 + _apply_offset(client, 0.0, 0.0, -25.0) + assert client.tcp_offset() == pytest.approx([0.0, 0.0, -25.0]) - assert [float(v) for v in client.tcp_offset()] == [0.0, 0.0, 0.0] + # Clearing back to zero answers a value, and the readback moved to prove it. + _apply_offset(client, 0.0, 0.0, 0.0) + assert client.tcp_offset() == pytest.approx([0.0, 0.0, 0.0]) - transform = [float(v) for v in client.tcp_transform()] + transform = client.tcp_transform() assert len(transform) == 6 assert all(isfinite(v) for v in transform)