Skip to content

Commit 45a3a87

Browse files
gonzalocasasclaude
andcommitted
fix(Ros.close): block until the WebSocket is actually torn down
Previously Ros.close() returned as soon as proto.send_close() had been dispatched onto the reactor thread: it set wait_disconnect inside the on_ready callback, before clientConnectionLost ever fired. Downstream callers (compas_fab's RosClient.__exit__ -> Ros.close() -> immediately construct a new Ros) could then race the previous socket's TCP teardown, and on contended reactors the next Ros.run() occasionally tripped its 10s connection timeout. close() now registers a one-shot listener on the factory's "close" event (emitted from clientConnectionLost when the underlying TCP connection has actually been lost) and only sets wait_disconnect from there. The "closing" event still fires synchronously inside the on_ready callback before send_close(), so handlers that depend on that ordering are unaffected. If the disconnect doesn't complete within the timeout the listener is detached before raising RosTimeoutError, so a subsequent reconnect cycle on the same factory won't leak a reference to this Ros instance. Verified by a new test_close_blocks_until_disconnect case in tests/ros1/test_lifecycle.py. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 50cc828 commit 45a3a87

3 files changed

Lines changed: 67 additions & 11 deletions

File tree

CHANGELOG.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ Unreleased
1414

1515
**Changed**
1616

17+
* ``Ros.close()`` now blocks until the underlying WebSocket has actually torn
18+
down — it waits for the factory's ``"close"`` event (emitted from
19+
``clientConnectionLost``) instead of returning as soon as ``send_close()`` is
20+
dispatched on the reactor thread. Downstream callers that immediately
21+
construct a new ``Ros`` no longer race the previous socket's cleanup. The
22+
``"closing"`` event semantics are unchanged.
23+
1724
**Fixed**
1825

1926
* ``TwistedEventLoopManager`` no longer leaks one ``PythonLoggingObserver`` per

src/roslibpy/ros.py

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,20 +76,44 @@ def _unset_connecting_flag(*args):
7676
self.factory.connect()
7777

7878
def close(self, timeout=CONNECTION_TIMEOUT):
79-
"""Disconnect from ROS."""
80-
if self.is_connected:
81-
wait_disconnect = threading.Event()
79+
"""Disconnect from ROS.
80+
81+
Blocks until the underlying WebSocket has actually torn down
82+
(``clientConnectionLost`` fired). Prior to 2.1, ``close()``
83+
returned as soon as ``send_close()`` had been dispatched on the
84+
reactor thread; callers that immediately constructed a new ``Ros``
85+
could race the previous socket's cleanup, occasionally causing
86+
the next ``run()`` to time out under contention.
87+
"""
88+
if not self.is_connected:
89+
return
90+
91+
wait_disconnect = threading.Event()
8292

83-
def _wrapper_callback(proto):
84-
self.emit("closing")
85-
proto.send_close()
86-
wait_disconnect.set()
87-
return proto
93+
def _on_close(*_args):
94+
wait_disconnect.set()
8895

89-
self.factory.on_ready(_wrapper_callback)
96+
# `clientConnectionLost` on the factory emits the "close" event once
97+
# the websocket has actually been torn down. Use `once` so the
98+
# listener is auto-removed on first fire — protects against the
99+
# ReconnectingClientFactory triggering a later non-manual close.
100+
self.factory.once("close", _on_close)
101+
102+
def _wrapper_callback(proto):
103+
self.emit("closing")
104+
proto.send_close()
105+
return proto
106+
107+
self.factory.on_ready(_wrapper_callback)
90108

91-
if not wait_disconnect.wait(timeout):
92-
raise RosTimeoutError("Failed to disconnect to ROS")
109+
if not wait_disconnect.wait(timeout):
110+
# Detach the listener so it doesn't fire on a later reconnect
111+
# cycle and leak a reference to this Ros instance.
112+
try:
113+
self.factory.off("close", _on_close)
114+
except Exception:
115+
pass
116+
raise RosTimeoutError("Failed to disconnect to ROS")
93117

94118
def run(self, timeout=CONNECTION_TIMEOUT):
95119
"""Kick-starts a non-blocking event loop.

tests/ros1/test_lifecycle.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
from __future__ import print_function
1212

13+
import threading
14+
1315
from roslibpy import Ros
1416

1517
HOST = "127.0.0.1"
@@ -48,3 +50,26 @@ def test_log_observer_does_not_leak():
4850
)
4951

5052

53+
def test_close_blocks_until_disconnect():
54+
"""``Ros.close()`` must not return until the WebSocket is actually closed.
55+
56+
Before A2 the call returned once ``send_close`` had been dispatched on
57+
the reactor thread — the protocol's ``onClose`` would fire shortly
58+
after, leaving a brief window where ``is_connected`` could still read
59+
True. Now ``close()`` blocks on ``clientConnectionLost`` so the
60+
contract matches the name.
61+
"""
62+
ros = Ros(URL)
63+
ros.run()
64+
assert ros.is_connected
65+
66+
closed_event = threading.Event()
67+
ros.on("close", lambda _: closed_event.set())
68+
69+
ros.close()
70+
71+
# The "close" event must have fired by the time close() returned.
72+
assert closed_event.is_set(), "close() returned before clientConnectionLost fired"
73+
assert not ros.is_connected, "is_connected still True after close()"
74+
75+

0 commit comments

Comments
 (0)