From 0f6e95d10247b532818e43ae2cbba5cf3a4073a6 Mon Sep 17 00:00:00 2001 From: yd-sl <166189879+yd-sl@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:44:52 +0800 Subject: [PATCH] fix: keep the leader's resident publisher across teleop sessions teleop_start registered the leader's resident zenoh publisher as the session's transport, so teleop_stop closed it. _teleop_pub kept pointing at that dead endpoint, so every session after the first published into a closed zenoh session. On hardware the first master/slave pairing delivered frames and every later one delivered none, while the leader's own frame counter kept climbing. The resident publisher is no longer registered, so teleop_stop closes only the follower's per-session subscriber and the publisher lives until disconnect(). --- src/litegrip/gripper.py | 7 +++++- tests/test_teleop.py | 50 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/litegrip/gripper.py b/src/litegrip/gripper.py index fe3761f..2f50f15 100644 --- a/src/litegrip/gripper.py +++ b/src/litegrip/gripper.py @@ -1899,10 +1899,15 @@ def teleop_start( if transport is None: if link == "zenoh": if mode == "master": + # ⚠ 常驻 publisher **不登记**成 per-session transport:登记了 + # `teleop_stop` 就会 close 它,而 `_teleop_pub` 仍指向这个 + # 已死的端点 ⇒ 此后每一轮都在往死会话里 put。(真机实测: + # 第一次配对正常,之后每次从端 0 帧。)常驻端点的生命周期 + # 只归 `disconnect()` → `_close_teleop_pub()`。 transport = self._open_teleop_pub(port, key) else: transport = _zenoh_transport("slave", key, port, host) - created_transport = transport + created_transport = transport elif link == "udp": if host is None: raise ValueError("host is required for the udp link") diff --git a/tests/test_teleop.py b/tests/test_teleop.py index 08ad359..42bb8dd 100644 --- a/tests/test_teleop.py +++ b/tests/test_teleop.py @@ -349,6 +349,56 @@ def test_start_refuses_zenoh_link_without_the_extra(self): with self.assertRaises(ImportError): g.teleop_start("master") + def test_master_keeps_its_resident_publisher_across_sessions(self): + """A stop must not close the leader's resident zenoh publisher. + + Registering it as the per-session transport made ``teleop_stop`` close + it, while ``_teleop_pub`` kept pointing at the dead endpoint — so every + session after the first published into nothing. On hardware the first + pairing delivered frames and every later one delivered none, while the + leader's own frame counter kept climbing. + """ + g, _ = make_gripper() + from litegrip import gripper as gripper_mod + built = [] + + class _FakeLink: + def __init__(self): + self.closed = False + self.puts = 0 + + def pub(self, topic, payload): + self.puts += 1 + + def close(self): + self.closed = True + + def _factory(role, key, port, host): + built.append(_FakeLink()) + return built[-1] + + with unittest.mock.patch.object(gripper_mod, "_zenoh_transport", + _factory): + g.teleop_start("master", rate_hz=200.0) + g.teleop_stop() + resident = g._teleop_pub + self.assertIsInstance(resident, _FakeLink) + self.assertFalse( + resident.closed, + "teleop_stop must not close the resident publisher") + self.assertTrue(_wait_until(lambda: resident.puts > 0)) + + g.teleop_start("master", rate_hz=200.0) + self.assertIs(g._teleop_pub, resident) + self.assertEqual(len(built), 1, + "the listener is built once, not per session") + g.teleop_stop() + self.assertFalse(resident.closed) + + g.disconnect() + self.assertTrue(resident.closed, + "disconnect() owns the resident publisher") + class ReadinessTest(unittest.TestCase): """The three preconditions that must hold before anything is enabled."""