Skip to content

Commit 5b22c97

Browse files
committed
fix(TCPSocket): make connection attempts cancellable to unblock teardown
Replace the state-based, sleep-polling reconnect interruption with a dedicated, sticky cancellation flag (stop_requested_) plus requestStop()/clearStop(). The flag is orthogonal to SocketState, so setup()'s internal state resets can no longer race away a teardown signal -- the lost-wakeup that hung ~PrimaryClient() (and the Windows CI test) indefinitely. setup() now connects with a non-blocking socket polled in short slices (poll()/WSAPoll()), so a connect attempt against a genuinely unreachable host is interruptible too -- not just the between-attempt back-off. This makes the destructors return promptly instead of blocking for the reconnection timeout (or forever with unlimited attempts). - TCPSocket: add stop_requested_ + requestStop()/clearStop(); interruptible, non-blocking openInterruptible(); honor the flag in setup()'s connect loop and back-off. - PrimaryClient/RTDEClient: call requestStop() before joining the reconnect thread; clear the flag on (re)start (URProducer::setupProducer, RTDEClient::init). - tests: drive setup() interruption via requestStop(); add a non-routable-address blocking-connect regression test; guard the teardown tests with a watchdog; add CTest TIMEOUT properties so a hang fails fast instead of timing out the job.
1 parent 657a1bc commit 5b22c97

9 files changed

Lines changed: 326 additions & 62 deletions

File tree

include/ur_client_library/comm/producer.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ class URProducer : public IProducer<T>
133133
void setupProducer(const size_t max_num_tries = 0,
134134
const std::chrono::milliseconds reconnection_time = std::chrono::seconds(10)) override
135135
{
136+
// Clear any cancellation request left over from a previous teardown so the stream can be
137+
// (re)used. Safe here because this runs on the controlling thread before the producer loop.
138+
stream_.clearStop();
136139
timeval tv;
137140
tv.tv_sec = 1;
138141
tv.tv_usec = 0;

include/ur_client_library/comm/tcp_socket.h

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,22 @@ class TCPSocket
5353
std::chrono::milliseconds reconnection_time_;
5454
bool reconnection_time_modified_deprecated_ = false;
5555

56+
// Cancellation token used to abort a connection attempt that is currently in
57+
// progress (either waiting inside connect() or sleeping between attempts).
58+
// This is intentionally orthogonal to state_: a SocketState::Closed left over
59+
// from a deliberate close()+connect() reconnect must NOT abort a fresh
60+
// attempt, whereas requestStop() (used at teardown) must abort reliably and
61+
// cannot be clobbered by setup()'s internal state_ resets.
62+
std::atomic<bool> stop_requested_{ false };
63+
5664
void setupOptions();
5765

66+
// Performs an interruptible, non-blocking connect on an already-created socket.
67+
// Polls in short slices so that a concurrent requestStop() aborts the attempt
68+
// promptly on all platforms (POSIX close() of a blocked connect() is reliable,
69+
// Winsock's is not). Restores blocking mode on success.
70+
bool openInterruptible(socket_t socket_fd, struct sockaddr* address, size_t address_len);
71+
5872
protected:
5973
static bool open(socket_t socket_fd, struct sockaddr* address, size_t address_len)
6074
{
@@ -137,6 +151,26 @@ class TCPSocket
137151
*/
138152
void close();
139153

154+
/*!
155+
* \brief Requests that any in-progress (or future) connection attempt be aborted.
156+
*
157+
* Sets a sticky cancellation flag and closes the socket. A reconnect thread that is
158+
* waiting inside setup() (either in connect() or in the between-attempt back-off) returns
159+
* promptly instead of blocking until the reconnection timeout expires. Use this at teardown
160+
* (e.g. from a destructor) before joining a reconnect thread. The flag stays set until
161+
* clearStop() is called, so it cannot be lost by a racing internal state reset.
162+
*/
163+
void requestStop();
164+
165+
/*!
166+
* \brief Clears the cancellation flag set by requestStop().
167+
*
168+
* Must be called on a controlled (re)start before attempting to connect again, so a socket
169+
* that was previously stopped can be reused. Never call this concurrently with a reconnect
170+
* thread.
171+
*/
172+
void clearStop();
173+
140174
/*!
141175
* \brief Setup Receive timeout used for this socket.
142176
*

src/comm/tcp_socket.cpp

Lines changed: 162 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727

2828
#ifndef _WIN32
2929
# include <arpa/inet.h>
30+
# include <fcntl.h>
3031
# include <netinet/tcp.h>
32+
# include <poll.h>
3133
#endif
3234

3335
#include "ur_client_library/log.h"
@@ -37,6 +39,67 @@ namespace urcl
3739
{
3840
namespace comm
3941
{
42+
namespace
43+
{
44+
// Time slice used while waiting for a non-blocking connect to resolve. Kept short so a
45+
// concurrent requestStop() aborts the wait promptly even if closing the socket does not
46+
// itself wake the wait (as is the case on Windows).
47+
constexpr int CONNECT_POLL_SLICE_MS = 100;
48+
49+
// Toggle the blocking mode of a socket. Returns true on success.
50+
bool setSocketBlocking(socket_t socket_fd, bool blocking)
51+
{
52+
#ifdef _WIN32
53+
u_long mode = blocking ? 0 : 1;
54+
return ::ioctlsocket(socket_fd, FIONBIO, &mode) == 0;
55+
#else
56+
int flags = ::fcntl(socket_fd, F_GETFL, 0);
57+
if (flags < 0)
58+
{
59+
return false;
60+
}
61+
flags = blocking ? (flags & ~O_NONBLOCK) : (flags | O_NONBLOCK);
62+
return ::fcntl(socket_fd, F_SETFL, flags) == 0;
63+
#endif
64+
}
65+
66+
// True if the last connect() call indicated that the connection is being established
67+
// asynchronously (the expected result for a non-blocking socket).
68+
bool connectInProgress()
69+
{
70+
#ifdef _WIN32
71+
return ::WSAGetLastError() == WSAEWOULDBLOCK;
72+
#else
73+
return errno == EINPROGRESS;
74+
#endif
75+
}
76+
77+
// Waits up to timeout_ms for the socket to become writable (connect resolved).
78+
// Returns >0 if the socket is ready/has an event, 0 on timeout, <0 on error.
79+
//
80+
// poll() is used on both platforms (WSAPoll() on Windows). It avoids select()'s FD_SETSIZE
81+
// limitation entirely and keeps a single mental model. On Windows this relies on the WSAPoll
82+
// connect-failure fix introduced in Windows 10 version 2004 / Windows Server 2019: a failed
83+
// non-blocking connect is reported as (POLLHUP | POLLERR | POLLWRNORM). The caller treats any
84+
// returned event as "connect resolved" and consults SO_ERROR for the actual outcome, so it does
85+
// not depend on which particular revents flag is set.
86+
int waitForSocketWritable(socket_t socket_fd, int timeout_ms)
87+
{
88+
#ifdef _WIN32
89+
WSAPOLLFD pfd;
90+
pfd.fd = socket_fd;
91+
pfd.events = POLLWRNORM; // == POLLOUT
92+
pfd.revents = 0;
93+
return ::WSAPoll(&pfd, 1, timeout_ms);
94+
#else
95+
struct pollfd pfd;
96+
pfd.fd = socket_fd;
97+
pfd.events = POLLOUT;
98+
pfd.revents = 0;
99+
return ::poll(&pfd, 1, timeout_ms);
100+
#endif
101+
}
102+
} // namespace
40103
TCPSocket::TCPSocket()
41104
: socket_fd_(INVALID_SOCKET), state_(SocketState::Invalid), reconnection_time_(std::chrono::seconds(10))
42105
{
@@ -72,6 +135,66 @@ void TCPSocket::setupOptions()
72135
}
73136
}
74137

138+
bool TCPSocket::openInterruptible(socket_t socket_fd, struct sockaddr* address, size_t address_len)
139+
{
140+
if (!setSocketBlocking(socket_fd, false))
141+
{
142+
return false;
143+
}
144+
145+
int connect_res = ::connect(socket_fd, address, static_cast<socklen_t>(address_len));
146+
bool connected = false;
147+
if (connect_res == 0)
148+
{
149+
// Connected immediately (common for loopback).
150+
connected = true;
151+
}
152+
else if (connectInProgress())
153+
{
154+
// Poll in short slices until the connect resolves, the OS connect timeout expires,
155+
// or a concurrent requestStop() asks us to abort.
156+
while (true)
157+
{
158+
if (stop_requested_)
159+
{
160+
return false;
161+
}
162+
int ready = waitForSocketWritable(socket_fd, CONNECT_POLL_SLICE_MS);
163+
if (ready < 0)
164+
{
165+
// poll() error (e.g. the fd was closed by requestStop()).
166+
return false;
167+
}
168+
if (ready == 0)
169+
{
170+
// Timeout slice elapsed without the connect resolving: re-check stop and keep waiting.
171+
continue;
172+
}
173+
// The socket reported an event: query SO_ERROR to find out whether the connect succeeded.
174+
int so_error = 0;
175+
socklen_t len = sizeof(so_error);
176+
if (::getsockopt(socket_fd, SOL_SOCKET, SO_ERROR, reinterpret_cast<char*>(&so_error), &len) < 0)
177+
{
178+
return false;
179+
}
180+
connected = (so_error == 0);
181+
break;
182+
}
183+
}
184+
else
185+
{
186+
// Immediate, permanent failure (e.g. connection refused).
187+
connected = false;
188+
}
189+
190+
if (connected && !setSocketBlocking(socket_fd, true))
191+
{
192+
// Could not restore blocking mode; treat the connection as failed.
193+
return false;
194+
}
195+
return connected;
196+
}
197+
75198
bool TCPSocket::setup(const std::string& host, const int port, const size_t max_num_tries,
76199
const std::chrono::milliseconds reconnection_time)
77200
{
@@ -85,13 +208,17 @@ bool TCPSocket::setup(const std::string& host, const int port, const size_t max_
85208
reconnection_time_resolved = reconnection_time_;
86209
}
87210

211+
// Honor a pending stop before doing anything else (e.g. a connect attempt issued just after
212+
// requestStop()). Checked before touching state_ so the cancellation cannot be lost.
213+
if (stop_requested_)
214+
return false;
215+
88216
if (state_ == SocketState::Connected)
89217
return false;
90218

91-
// Clear any pre-existing Closed/Disconnected/Invalid state so that the
92-
// between-attempt sleep below (which exits early when state becomes Closed)
93-
// can only be short-circuited by a concurrent external close() call, not by
94-
// a Closed state left over from a previous disconnect().
219+
// Clear any leftover Closed/Disconnected state from a previous (deliberate) disconnect() so
220+
// it does not interfere with this fresh attempt. Cancellation is tracked via stop_requested_,
221+
// not via state_, so this reset can no longer erase a teardown signal.
95222
state_ = SocketState::Invalid;
96223

97224
URCL_LOG_DEBUG("Setting up connection: %s:%d", host.c_str(), port);
@@ -112,6 +239,9 @@ bool TCPSocket::setup(const std::string& host, const int port, const size_t max_
112239
bool connected = false;
113240
while (!connected)
114241
{
242+
if (stop_requested_)
243+
return false;
244+
115245
if (getaddrinfo(host_name, service.c_str(), &hints, &result) != 0)
116246
{
117247
URCL_LOG_ERROR("Failed to get address for %s:%d", host.c_str(), port);
@@ -122,18 +252,28 @@ bool TCPSocket::setup(const std::string& host, const int port, const size_t max_
122252
{
123253
socket_fd_ = ::socket(p->ai_family, p->ai_socktype, p->ai_protocol);
124254

125-
if (socket_fd_ != -1 && open(socket_fd_, p->ai_addr, p->ai_addrlen))
255+
if (socket_fd_ != -1 && openInterruptible(socket_fd_, p->ai_addr, p->ai_addrlen))
126256
{
127257
connected = true;
128258
break;
129259
}
260+
261+
if (stop_requested_)
262+
{
263+
freeaddrinfo(result);
264+
return false;
265+
}
130266
}
131267

132268
freeaddrinfo(result);
133269

134270
if (!connected)
135271
{
136272
state_ = SocketState::Invalid;
273+
if (stop_requested_)
274+
{
275+
return false;
276+
}
137277
if (++connect_counter >= max_num_tries && max_num_tries > 0)
138278
{
139279
URCL_LOG_ERROR("Failed to establish connection for %s:%d after %d tries", host.c_str(), port, max_num_tries);
@@ -147,16 +287,15 @@ bool TCPSocket::setup(const std::string& host, const int port, const size_t max_
147287
<< std::chrono::duration_cast<std::chrono::duration<float>>(reconnection_time_resolved).count()
148288
<< " seconds.";
149289
URCL_LOG_ERROR("%s", ss.str().c_str());
150-
// Sleep in short slices so that an external close() (e.g. from ~RTDEClient calling
151-
// disconnect() before joining the reconnect thread) can interrupt the wait promptly.
290+
// Sleep in short slices so that a concurrent requestStop() (e.g. from ~RTDEClient or
291+
// ~PrimaryClient before joining the reconnect thread) can interrupt the back-off promptly.
152292
const auto sleep_slice = std::chrono::milliseconds(100);
153-
for (auto slept = std::chrono::milliseconds(0);
154-
slept < reconnection_time_resolved && state_ != SocketState::Closed;
293+
for (auto slept = std::chrono::milliseconds(0); slept < reconnection_time_resolved && !stop_requested_;
155294
slept += sleep_slice)
156295
{
157296
std::this_thread::sleep_for(sleep_slice);
158297
}
159-
if (state_ == SocketState::Closed)
298+
if (stop_requested_)
160299
{
161300
return false;
162301
}
@@ -179,6 +318,19 @@ void TCPSocket::close()
179318
}
180319
}
181320

321+
void TCPSocket::requestStop()
322+
{
323+
// Set the flag before closing so that a reconnect thread observing the closed socket is
324+
// guaranteed to also see the cancellation request (and therefore stop instead of retrying).
325+
stop_requested_ = true;
326+
close();
327+
}
328+
329+
void TCPSocket::clearStop()
330+
{
331+
stop_requested_ = false;
332+
}
333+
182334
std::string TCPSocket::getIP() const
183335
{
184336
sockaddr_in name;

src/primary/primary_client.cpp

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,12 @@ PrimaryClient::PrimaryClient(const std::string& robot_ip, [[maybe_unused]] comm:
6767
PrimaryClient::~PrimaryClient()
6868
{
6969
URCL_LOG_INFO("Stopping primary client pipeline");
70-
// Close the stream BEFORE stopping (joining) the pipeline. The pipeline's
70+
// Request a stop on the stream BEFORE stopping (joining) the pipeline. The pipeline's
7171
// producer thread may be sleeping inside its reconnect backoff or blocked in
72-
// TCPSocket::setup(); closing the stream sets SocketState::Closed, which wakes
73-
// both paths so pipeline_->stop()'s join returns promptly instead of blocking
74-
// until the (potentially unbounded) reconnect timeout expires.
75-
stream_.close();
72+
// TCPSocket::setup(); requestStop() sets the cancellation flag and closes the socket so
73+
// both paths abort within one poll slice and pipeline_->stop()'s join returns promptly
74+
// instead of blocking until the (potentially unbounded) reconnect timeout expires.
75+
stream_.requestStop();
7676
pipeline_->stop();
7777
}
7878

@@ -85,9 +85,10 @@ void PrimaryClient::start(const size_t max_num_tries, const std::chrono::millise
8585

8686
void PrimaryClient::stop()
8787
{
88-
// Close the stream before joining the pipeline so a producer thread stuck in
89-
// its reconnect path is woken and the join returns promptly (see ~PrimaryClient).
90-
stream_.close();
88+
// Request a stop on the stream before joining the pipeline so a producer thread stuck in
89+
// its reconnect path is aborted and the join returns promptly (see ~PrimaryClient). A
90+
// subsequent start() clears the flag again via URProducer::setupProducer().
91+
stream_.requestStop();
9192
pipeline_->stop();
9293
}
9394

src/rtde/rtde_client.cpp

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,11 @@ RTDEClient::~RTDEClient()
8787
{
8888
prod_->setReconnectionCallback(nullptr);
8989
stop_reconnection_ = true;
90-
// Disconnect before joining the reconnect thread so that any sleep inside
91-
// TCPSocket::setup() (which checks for SocketState::Closed) wakes promptly
92-
// instead of blocking the destructor for the full reconnection_timeout.
90+
// Request a stop on the stream before joining the reconnect thread. requestStop() sets the
91+
// cancellation flag and closes the socket so that any connect attempt or back-off sleep inside
92+
// TCPSocket::setup() aborts within one poll slice, instead of blocking the destructor for the
93+
// full reconnection_timeout (or indefinitely with max_connection_attempts == 0).
94+
stream_.requestStop();
9395
disconnect();
9496
if (reconnecting_thread_.joinable())
9597
{
@@ -115,6 +117,10 @@ bool RTDEClient::init(const size_t max_connection_attempts, const std::chrono::m
115117
max_initialization_attempts_ = max_initialization_attempts;
116118
initialization_timeout_ = initialization_timeout;
117119

120+
// Clear any cancellation request from a previous teardown so this (re)initialization can
121+
// connect. Runs on the controlling thread before the reconnect thread can be started.
122+
stream_.clearStop();
123+
118124
prod_->setReconnectionCallback(nullptr);
119125

120126
unsigned int attempts = 0;

tests/CMakeLists.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ if (INTEGRATION_TESTS)
3939
gtest_add_tests(TARGET rtde_tests
4040
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
4141
)
42+
# Bound this teardown regression test so a hang fails CI fast instead of timing out the job.
43+
set_tests_properties(RTDEClientTest.destructor_not_blocked_by_stuck_reconnect_thread
44+
PROPERTIES TIMEOUT 60)
4245
if (CHECK_RTDE_DOCS_RECIPE)
4346
find_package(Python3 COMPONENTS Interpreter REQUIRED)
4447
add_custom_target(generate_outputs ALL COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/resources/generate_rtde_outputs.py)
@@ -159,6 +162,9 @@ add_executable(primary_client_reconnect_tests test_primary_client_reconnect.cpp
159162
target_link_libraries(primary_client_reconnect_tests PRIVATE ur_client_library::urcl GTest::gtest_main)
160163
gtest_add_tests(TARGET primary_client_reconnect_tests
161164
)
165+
# Bound this teardown regression test so a hang fails CI in ~1 min instead of timing out the job.
166+
set_tests_properties(PrimaryClientReconnectTest.destructor_not_blocked_by_stuck_reconnect_thread
167+
PROPERTIES TIMEOUT 60)
162168

163169

164170

@@ -256,6 +262,10 @@ add_executable(tcp_socket_tests test_tcp_socket.cpp)
256262
target_link_libraries(tcp_socket_tests PRIVATE ur_client_library::urcl GTest::gtest_main)
257263
gtest_add_tests(TARGET tcp_socket_tests
258264
)
265+
# Bound the interruptible-setup regression tests so a hang fails CI fast instead of timing out the job.
266+
set_tests_properties(TCPSocketTest.setup_interruptible_by_close
267+
TCPSocketTest.setup_interruptible_during_blocking_connect
268+
PROPERTIES TIMEOUT 60)
259269

260270
add_executable(stream_tests test_stream.cpp)
261271
target_link_libraries(stream_tests PRIVATE ur_client_library::urcl GTest::gtest_main)

0 commit comments

Comments
 (0)