diff --git a/doc/images/tcp_socket_states.drawio b/doc/images/tcp_socket_states.drawio
new file mode 100644
index 000000000..7a1667a66
--- /dev/null
+++ b/doc/images/tcp_socket_states.drawio
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/doc/images/tcp_socket_states.svg b/doc/images/tcp_socket_states.svg
new file mode 100644
index 000000000..bcfffa6b1
--- /dev/null
+++ b/doc/images/tcp_socket_states.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/include/ur_client_library/comm/producer.h b/include/ur_client_library/comm/producer.h
index e509d9017..5843f57f7 100644
--- a/include/ur_client_library/comm/producer.h
+++ b/include/ur_client_library/comm/producer.h
@@ -69,12 +69,33 @@ class URProducer : public IProducer
if (!running_)
return false;
- if (stream_.getState() == SocketState::Connected)
+ const SocketState state = stream_.getState();
+
+ switch (state)
{
- continue;
+ case SocketState::Invalid:
+ URCL_LOG_WARN("Stream is invalid. Connect it before trying to read!");
+ break;
+ case SocketState::Connecting:
+ URCL_LOG_WARN("Stream is connecting but not ready, yet. Re-attempting to read!");
+ continue;
+ break;
+ case SocketState::Connected:
+ URCL_LOG_WARN("Stream is connected but failed to read from it. Re-attempting to read!");
+ continue;
+ break;
+ case SocketState::LostConnection:
+ URCL_LOG_WARN("Lost connection to stream, attempting to reconnect...");
+ break;
+ case SocketState::Disconnecting:
+ URCL_LOG_WARN("Stream is disconnecting. Connect it before trying to read!");
+ break;
+ case SocketState::Closed:
+ URCL_LOG_WARN("Stream is disconnected. Connect it before trying to read!");
+ break;
}
- if (stream_.closed())
+ if (stream_.closed() || stream_.stopRequested())
return false;
if (on_reconnect_cb_)
@@ -85,9 +106,23 @@ class URProducer : public IProducer
}
URCL_LOG_WARN("Failed to read from stream, reconnecting in %ld seconds...", timeout_.count());
- std::this_thread::sleep_for(timeout_);
+ // Sleep in small slices so the producer can be stopped (running_ == false)
+ // or woken by a stream close (e.g. from a destructor calling stop()) within
+ // ~100 ms, instead of blocking for the full (exponentially growing) timeout.
+ // Without this, a thread joining the pipeline at teardown could block for up
+ // to 120 s while this thread sleeps here.
+ const auto sleep_slice = std::chrono::milliseconds(100);
+ const auto sleep_total = std::chrono::duration_cast(timeout_);
+ for (auto slept = std::chrono::milliseconds(0);
+ slept < sleep_total && running_ && !stream_.closed() && !stream_.stopRequested(); slept += sleep_slice)
+ {
+ std::this_thread::sleep_for(sleep_slice);
+ }
+
+ if (!running_ || stream_.closed() || stream_.stopRequested())
+ return false;
- if (stream_.connect())
+ if (stream_.reconnect())
continue;
auto next = timeout_ * 2;
diff --git a/include/ur_client_library/comm/stream.h b/include/ur_client_library/comm/stream.h
index 8a573492e..b8da6bfee 100644
--- a/include/ur_client_library/comm/stream.h
+++ b/include/ur_client_library/comm/stream.h
@@ -63,16 +63,32 @@ class URStream : public TCPSocket
bool connect(const size_t max_num_tries = 0,
const std::chrono::milliseconds reconnection_time = std::chrono::seconds(10))
{
- return TCPSocket::setup(host_, port_, max_num_tries, reconnection_time);
+ return TCPSocket::connect(host_, port_, max_num_tries, reconnection_time);
}
/*!
- * \brief Disconnects from the configured socket.
+ * \brief Re-establishes the connection after an unexpected drop, without clearing a deliberate
+ * disconnect(). Used by the automatic reconnect path.
+ *
+ * \param max_num_tries Maximum number of connection attempts before failing. Unlimited when 0.
+ * \param reconnection_time time in between connection attempts to the server
+ *
+ * \returns True on success, false if it could not reconnect or a deliberate disconnect() is in
+ * effect
+ */
+ bool reconnect(const size_t max_num_tries = 0,
+ const std::chrono::milliseconds reconnection_time = std::chrono::seconds(10))
+ {
+ return TCPSocket::reconnect(host_, port_, max_num_tries, reconnection_time);
+ }
+
+ /*!
+ * \brief Deliberately disconnects from the configured socket, leaving it ready to connect again.
*/
void disconnect()
{
URCL_LOG_DEBUG("Disconnecting from %s:%d", host_.c_str(), port_);
- TCPSocket::close();
+ TCPSocket::disconnect();
}
/*!
@@ -83,6 +99,15 @@ class URStream : public TCPSocket
return getState() == SocketState::Closed;
}
+ /*!
+ * \brief Returns whether a deliberate disconnect() is in progress or in effect (the socket will
+ * not auto-reconnect until connect() is called again).
+ */
+ bool stopRequested()
+ {
+ return TCPSocket::isStopRequested();
+ }
+
/*!
* \brief Reads a full UR package out of a socket. For this, it looks into the package and reads
* the byte length from the socket directly. It returns as soon as all bytes for the package are
diff --git a/include/ur_client_library/comm/tcp_server.h b/include/ur_client_library/comm/tcp_server.h
index 465e79073..d95cbe0e2 100644
--- a/include/ur_client_library/comm/tcp_server.h
+++ b/include/ur_client_library/comm/tcp_server.h
@@ -40,6 +40,10 @@
#include "ur_client_library/comm/socket_t.h"
+#ifndef _WIN32
+# include
+#endif
+
namespace urcl
{
namespace comm
@@ -198,6 +202,14 @@ class TCPServer
return port_;
}
+protected:
+ // Test hook: number of file descriptors currently in the cached poll set
+ // (listen socket + one per connected client).
+ size_t getPollSetSize() const
+ {
+ return pollfds_.size();
+ }
+
private:
void init();
void bind(const size_t max_num_tries, const std::chrono::milliseconds reconnection_time);
@@ -217,18 +229,35 @@ class TCPServer
//! Runs spin() as long as keep_running_ is set to true.
void worker();
+ //! Rebuilds the cached poll set (pollfds_) from the listen socket and client_fds_.
+ void rebuildPollfds();
+
+ // Number of client slots the poll set reserves up front when the client count is unbounded
+ // (max_clients_allowed_ == 0). Bounded servers reserve their exact limit instead. This is just
+ // headroom so the first few connects (incl. transient overlap during a robot reconnect) don't
+ // reallocate; the vector still grows geometrically beyond it.
+ static constexpr uint32_t DEFAULT_RESERVED_CLIENTS = 8;
+
std::atomic keep_running_{ false };
std::thread worker_thread_;
std::atomic listen_fd_;
int port_;
- socket_t maxfd_;
- fd_set masterfds_;
- fd_set tempfds_;
-
uint32_t max_clients_allowed_;
std::vector client_fds_;
+
+#ifdef _WIN32
+ using PollFd = WSAPOLLFD;
+#else
+ using PollFd = struct pollfd;
+#endif
+ // Cached poll set: index 0 is the listen socket, the rest mirror client_fds_. Only touched by
+ // the worker thread (seeded in start() before the thread launches, rebuilt in
+ // handleConnect()/handleDisconnect(), cleared in shutdown() after the worker is joined), so
+ // spin() can poll() on it directly without per-iteration allocation or extra locking.
+ std::vector pollfds_;
+
std::mutex clients_mutex_;
std::mutex message_mutex_;
std::mutex listen_fd_mutex_;
diff --git a/include/ur_client_library/comm/tcp_socket.h b/include/ur_client_library/comm/tcp_socket.h
index 5a1d468c5..2d442d44c 100644
--- a/include/ur_client_library/comm/tcp_socket.h
+++ b/include/ur_client_library/comm/tcp_socket.h
@@ -36,12 +36,16 @@ namespace comm
*/
enum class SocketState
{
- Invalid, ///< Socket is initialized or setup failed
- Connected, ///< Socket is connected and ready to use
- Disconnected, ///< Socket is disconnected and cannot be used
- Closed ///< Connection to socket got closed
+ Invalid, ///< Socket is initialized but was never connected
+ Connecting, ///< A connection is in progress
+ Connected, ///< Socket is connected and ready to use
+ LostConnection, ///< Connection dropped unexpectedly; auto-reconnect is expected to pick it up
+ Disconnecting, ///< A deliberate disconnect() is in progress
+ Closed, ///< Connection to socket got closed
};
+const std::string& socketStateToString(SocketState state);
+
/*!
* \brief Class for TCP socket abstraction
*/
@@ -50,19 +54,64 @@ class TCPSocket
private:
std::atomic socket_fd_;
std::atomic state_;
+ std::atomic target_state_;
std::chrono::milliseconds reconnection_time_;
bool reconnection_time_modified_deprecated_ = false;
void setupOptions();
+ // Performs an interruptible, non-blocking connect on an already-created socket.
+ // Polls in short slices so that a concurrent disconnect() aborts the attempt
+ // promptly on all platforms (POSIX close() of a blocked connect() is reliable,
+ // Winsock's is not). Restores blocking mode on success.
+ bool openInterruptible(socket_t socket_fd, struct sockaddr* address, size_t address_len);
+
+ bool setupInternal(const std::string& host, const int port, const size_t max_num_tries,
+ const std::chrono::milliseconds reconnection_time);
+
+ void freeFileDescriptor();
+
protected:
+ /*!
+ * \brief Atomically moves state_ to `desired`, unless a deliberate disconnect() is in effect.
+ *
+ * Returns true if the state was set, false if a deliberate stop is
+ * active (in which case state_ is left untouched). This is how the connect/retry machinery
+ * updates its in-progress state without ever clobbering a teardown signal.
+ */
+ bool setTargetStateUnlessStopRequested(SocketState desired);
+
+ /*!
+ * \brief Query whether there has been a deliberate disconnect() request.
+ *
+ * True while a deliberate disconnect() is in progress or has completed (the "deliberate-stop
+ * set").
+ */
+ bool isStopRequested() const
+ {
+ return target_state_ == SocketState::Closed;
+ }
+
+ /*!
+ * \brief Performs a blocking connect on an already-created socket.
+ *
+ * This is the platform-native, uninterruptible connect. It is used when the caller has
+ * already established that no deliberate disconnect() is in effect, and is willing to block
+ * until the connect succeeds or fails. The caller must ensure that the socket is in blocking
+ * mode before calling this.
+ */
+ [[deprecated("Use connect() instead, which is interruptible by a concurrent disconnect()")]]
static bool open(socket_t socket_fd, struct sockaddr* address, size_t address_len)
{
return ::connect(socket_fd, address, static_cast(address_len)) == 0;
}
+ [[deprecated("Use the public method connect() instead")]]
bool setup(const std::string& host, const int port, const size_t max_num_tries = 0,
- const std::chrono::milliseconds reconnection_time = DEFAULT_RECONNECTION_TIME);
+ const std::chrono::milliseconds reconnection_time = DEFAULT_RECONNECTION_TIME)
+ {
+ return connect(host, port, max_num_tries, reconnection_time);
+ }
std::unique_ptr recv_timeout_;
@@ -133,10 +182,60 @@ class TCPSocket
bool write(const uint8_t* buf, const size_t buf_len, size_t& written);
/*!
- * \brief Closes the connection to the socket.
+ * \brief Establishes a connection to the configured host/port.
+ *
+ * This is the explicit connection setup method. It clears any prior deliberate
+ * disconnect() (moving the socket to Connecting) and then attempts to connect, retrying up to
+ * max_num_tries times (unlimited when 0).
+ *
+ * \param host Host to connect to
+ * \param port Port to connect to
+ * \param max_num_tries Maximum number of connection attempts before failing. Unlimited when 0.
+ * \param reconnection_time Time between connection attempts
+ *
+ * \returns True on success, false if the connection could not be established or was aborted by
+ * a concurrent disconnect()
+ */
+ bool connect(const std::string& host, const int port, const size_t max_num_tries = 0,
+ const std::chrono::milliseconds reconnection_time = DEFAULT_RECONNECTION_TIME);
+
+ /*!
+ * \brief Reconnects to the configured host/port.
+ *
+ * This is the explicit reconnection method. It can only be called when the socket is in
+ * LostConnection state, and will attempt to reconnect, retrying up to max_num_tries times
+ * (unlimited when 0). Thus, when an explicit disconnect() is in effect, this will fail
+ * immediately. When a concurrent disconnect() is called while this is in progress, it will abort
+ * and return false.
+ *
+ * \param host Host to connect to
+ * \param port Port to connect to
+ * \param max_num_tries Maximum number of connection attempts before failing. Unlimited when 0.
+ * \param reconnection_time Time between connection attempts
+ *
+ * \returns True on success, false if the connection could not be established or was aborted by
+ * a concurrent disconnect()
+ */
+ bool reconnect(const std::string& host, const int port, const size_t max_num_tries = 0,
+ const std::chrono::milliseconds reconnection_time = DEFAULT_RECONNECTION_TIME);
+
+ /*!
+ * \brief Disconnects the client
+ *
+ * This overwrites connection attempts. When a deliberate disconnect() has been called, the
+ * socket will not execute any connection attempts until it has reached the CLOSED state and
+ * connect() is called again.
*/
void close();
+ /*!
+ * \brief Alias function for close() to disconnect the client
+ */
+ void disconnect()
+ {
+ close();
+ }
+
/*!
* \brief Setup Receive timeout used for this socket.
*
diff --git a/include/ur_client_library/comm/unique_fd.h b/include/ur_client_library/comm/unique_fd.h
new file mode 100644
index 000000000..7864ce518
--- /dev/null
+++ b/include/ur_client_library/comm/unique_fd.h
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2026, Universal Robots A/S
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include
+
+#include "ur_client_library/comm/socket_t.h"
+
+namespace urcl
+{
+namespace comm
+{
+/*!
+ * \brief RAII owner for a single socket descriptor.
+ *
+ * Owns the lifecycle of exactly one socket descriptor, closing it on destruction. To replace the
+ * held descriptor, call reset(new_fd), which closes the currently held descriptor (if any) before
+ * adopting the new one. This makes it impossible to accidentally leak a descriptor by overwriting
+ * the handle, e.g. when retrying a connection attempt.
+ *
+ * The descriptor is stored atomically, so a concurrent close/reset (as issued by a deliberate
+ * disconnect() from another thread) is safe against the connect path reading or replacing it. This
+ * matches the concurrency model TCPSocket relied on when the descriptor was a plain
+ * std::atomic.
+ *
+ * The class is non-copyable, mirroring std::unique_ptr's unique-ownership semantics.
+ */
+class UniqueFd
+{
+public:
+ UniqueFd() = default;
+ ~UniqueFd()
+ {
+ reset();
+ }
+
+ UniqueFd(const UniqueFd&) = delete;
+ UniqueFd& operator=(const UniqueFd&) = delete;
+
+ /*!
+ * \brief Closes the currently held descriptor (if valid) and adopts new_fd.
+ *
+ * \param new_fd The descriptor to adopt. Defaults to INVALID_SOCKET, which simply closes the
+ * currently held descriptor.
+ */
+ void reset(socket_t new_fd = INVALID_SOCKET)
+ {
+ socket_t old_fd = fd_.exchange(new_fd);
+ if (old_fd >= 0 && old_fd != new_fd)
+ {
+ ::ur_close(old_fd);
+ }
+ }
+
+ /*!
+ * \brief Returns the currently held descriptor without transferring ownership.
+ *
+ * \returns The held descriptor, or INVALID_SOCKET if none is held.
+ */
+ socket_t get() const
+ {
+ return fd_.load();
+ }
+
+private:
+ std::atomic fd_{ INVALID_SOCKET };
+};
+} // namespace comm
+} // namespace urcl
diff --git a/src/comm/tcp_server.cpp b/src/comm/tcp_server.cpp
index 3bb5559d5..6f040e7e1 100644
--- a/src/comm/tcp_server.cpp
+++ b/src/comm/tcp_server.cpp
@@ -34,15 +34,19 @@
#include
#include
+#include
#include "ur_client_library/comm/socket_t.h"
#include
+#ifndef _WIN32
+# include
+#endif
namespace urcl
{
namespace comm
{
TCPServer::TCPServer(const int port, const size_t max_num_tries, const std::chrono::milliseconds reconnection_time)
- : port_(port), maxfd_(0), max_clients_allowed_(0)
+ : port_(port), max_clients_allowed_(0)
{
#ifdef _WIN32
WSAData data;
@@ -74,9 +78,6 @@ void TCPServer::init()
ur_setsockopt(listen_fd_, SOL_SOCKET, SO_KEEPALIVE, &flag, sizeof(int));
URCL_LOG_DEBUG("Created socket with FD %d", (int)listen_fd_);
-
- FD_ZERO(&masterfds_);
- FD_ZERO(&tempfds_);
}
void TCPServer::shutdown()
@@ -139,6 +140,8 @@ void TCPServer::shutdown()
}
// This will effectively deactivate the disconnection handler.
client_fds_.clear();
+ // Worker thread has been joined above, so the poll set is no longer in use; keep it consistent.
+ pollfds_.clear();
ur_close(shutdown_socket);
ur_close(listen_fd_);
listen_fd_ = INVALID_SOCKET;
@@ -179,9 +182,6 @@ void TCPServer::bind(const size_t max_num_tries, const std::chrono::milliseconds
} while (err == -1 && (connection_counter <= max_num_tries || max_num_tries == 0));
URCL_LOG_DEBUG("Bound %d:%d to FD %d", server_addr.sin_addr.s_addr, port_, (int)listen_fd_);
-
- FD_SET(listen_fd_, &masterfds_);
- maxfd_ = listen_fd_;
}
void TCPServer::startListen()
@@ -220,21 +220,6 @@ void TCPServer::handleConnect()
return;
}
-#ifdef _WIN32
- bool set_size_exceeded = client_fds_.size() >= FD_SETSIZE - 1; // -1 because listen_fd_ also occupies one
- // slot in masterfds_
-#else
- bool set_size_exceeded = client_fd >= FD_SETSIZE; // On Unix-like systems, the client FD itself must be less than
- // FD_SETSIZE, otherwise it cannot be added to the fd_set.
-#endif
-
- if (set_size_exceeded)
- {
- URCL_LOG_ERROR("Accepted client FD %d exceeds FD_SETSIZE (%d). Closing connection.", (int)client_fd, FD_SETSIZE);
- ur_close(client_fd);
- return;
- }
-
bool accepted = false;
{
@@ -242,11 +227,7 @@ void TCPServer::handleConnect()
if (client_fds_.size() < max_clients_allowed_ || max_clients_allowed_ == 0)
{
client_fds_.push_back(client_fd);
- FD_SET(client_fd, &masterfds_);
- if (client_fd > maxfd_)
- {
- maxfd_ = client_fd;
- }
+ rebuildPollfds();
accepted = true;
}
else
@@ -266,48 +247,68 @@ void TCPServer::handleConnect()
}
}
-void TCPServer::spin()
+void TCPServer::rebuildPollfds()
{
- tempfds_ = masterfds_;
-
- timeval timeout;
- timeout.tv_sec = 1;
- timeout.tv_usec = 0;
+ // clear() + push_back() reuses the vector's existing capacity, so this does not allocate once
+ // the set has reached a given size.
+ pollfds_.clear();
+ pollfds_.push_back({ static_cast(listen_fd_), POLLIN, 0 });
+ for (const auto& client_fd : client_fds_)
+ {
+ pollfds_.push_back({ client_fd, POLLIN, 0 });
+ }
+}
- // blocks until activity on any socket from tempfds
- int sel = select(static_cast(maxfd_ + 1), &tempfds_, NULL, NULL, &timeout);
- if (sel < 0)
+void TCPServer::spin()
+{
+ // Poll the cached set (pollfds_) directly. It is kept in sync with client_fds_ via
+ // rebuildPollfds() on every connect/disconnect, so no allocation happens here in steady state.
+ // poll() is used on both platforms (WSAPoll() on Windows) because it has no FD_SETSIZE limit on
+ // file descriptor numbers, unlike select(). This matters when the hosting process holds many
+ // file descriptors (e.g. a JVM), pushing socket FDs past FD_SETSIZE (1024).
+ //
+ // Block for up to 1 s waiting for activity on any socket. A shutdown wakes this immediately by
+ // connecting to the listen socket (see shutdown()).
+#ifdef _WIN32
+ int ready = ::WSAPoll(pollfds_.data(), static_cast(pollfds_.size()), 1000);
+#else
+ int ready = ::poll(pollfds_.data(), pollfds_.size(), 1000);
+#endif
+ if (ready < 0)
{
- URCL_LOG_ERROR("select() failed. Shutting down socket event handler.");
+ URCL_LOG_ERROR("poll() failed. Shutting down socket event handler.");
keep_running_ = false;
return;
}
- if (!keep_running_ || sel == 0)
+ if (!keep_running_ || ready == 0)
{
return;
}
- if (FD_ISSET(listen_fd_, &tempfds_))
- {
- URCL_LOG_DEBUG("Activity on listen FD %d", (int)listen_fd_);
- handleConnect();
- }
+ // Snapshot the poll results into locals BEFORE handleConnect()/handleDisconnect() rebuild
+ // pollfds_, otherwise activity on an existing client that arrives in the same poll cycle as a
+ // new connection would be lost.
+ const bool listen_activity = (pollfds_[0].revents & POLLIN) != 0;
std::vector disconnected_clients;
std::vector client_fds_with_activity;
+ // pollfds_[0] is the listen socket; client entries start at index 1.
+ for (size_t i = 1; i < pollfds_.size(); ++i)
{
- std::lock_guard lk(clients_mutex_);
- for (const auto& client_fd : client_fds_)
+ if (pollfds_[i].revents & (POLLIN | POLLHUP | POLLERR))
{
- if (FD_ISSET(client_fd, &tempfds_))
- {
- URCL_LOG_DEBUG("Activity on client FD %d", (int)client_fd);
- client_fds_with_activity.push_back(client_fd);
- }
+ URCL_LOG_DEBUG("Activity on client FD %d", (int)pollfds_[i].fd);
+ client_fds_with_activity.push_back(static_cast(pollfds_[i].fd));
}
}
+
+ if (listen_activity)
+ {
+ URCL_LOG_DEBUG("Activity on listen FD %d", (int)listen_fd_);
+ handleConnect();
+ }
// We handle client activity outside the clients_mutex_ lock to avoid holding it during potentially slow I/O and
// message callbacks.
// The clients_mutex_ lock is only needed to protect the client_fds_ vector, but once we have copied the FDs with
@@ -331,7 +332,6 @@ void TCPServer::handleDisconnect(const socket_t fd)
{
std::lock_guard lk(clients_mutex_);
ur_close(fd);
- FD_CLR(fd, &masterfds_);
for (size_t i = 0; i < client_fds_.size(); ++i)
{
@@ -341,15 +341,7 @@ void TCPServer::handleDisconnect(const socket_t fd)
break;
}
}
-
- maxfd_ = listen_fd_;
- for (const auto& client_fd : client_fds_)
- {
- if (client_fd > maxfd_)
- {
- maxfd_ = client_fd;
- }
- }
+ rebuildPollfds();
}
{
@@ -415,6 +407,16 @@ void TCPServer::start()
{
URCL_LOG_DEBUG("Starting worker thread");
keep_running_ = true;
+ // Seed the poll set with the listen socket before the worker thread starts polling it. Reserve
+ // room for the listen socket plus the expected number of clients up front so the first
+ // connections don't reallocate. A bounded server reserves its exact client limit; an unbounded
+ // one (max_clients_allowed_ == 0) reserves DEFAULT_RESERVED_CLIENTS as headroom.
+ {
+ std::lock_guard lk(clients_mutex_);
+ const uint32_t expected_clients = max_clients_allowed_ > 0 ? max_clients_allowed_ : DEFAULT_RESERVED_CLIENTS;
+ pollfds_.reserve(static_cast(expected_clients) + 1);
+ rebuildPollfds();
+ }
worker_thread_ = std::thread(&TCPServer::worker, this);
}
diff --git a/src/comm/tcp_socket.cpp b/src/comm/tcp_socket.cpp
index 6af840e0d..d6384e94f 100644
--- a/src/comm/tcp_socket.cpp
+++ b/src/comm/tcp_socket.cpp
@@ -23,11 +23,14 @@
#include
#include
#include
+#include
#include
#ifndef _WIN32
# include
+# include
# include
+# include
#endif
#include "ur_client_library/log.h"
@@ -37,8 +40,111 @@ namespace urcl
{
namespace comm
{
+
+const std::string& socketStateToString(SocketState state)
+{
+ switch (state)
+ {
+ case SocketState::Invalid:
+ {
+ static const std::string invalid_state = "Invalid";
+ return invalid_state;
+ }
+ case SocketState::Connecting:
+ {
+ static const std::string connecting_state = "Connecting";
+ return connecting_state;
+ }
+ case SocketState::Connected:
+ {
+ static const std::string connected_state = "Connected";
+ return connected_state;
+ }
+ case SocketState::LostConnection:
+ {
+ static const std::string lost_connection_state = "LostConnection";
+ return lost_connection_state;
+ }
+ case SocketState::Disconnecting:
+ {
+ static const std::string disconnecting_state = "Disconnecting";
+ return disconnecting_state;
+ }
+ case SocketState::Closed:
+ {
+ static const std::string closed_state = "Closed";
+ return closed_state;
+ }
+ }
+ throw std::invalid_argument("Unknown socket state");
+}
+
+namespace
+{
+// Time slice used while waiting for a non-blocking connect to resolve. Kept short so a
+// concurrent disconnect() aborts the wait promptly even if closing the socket does not
+// itself wake the wait (as is the case on Windows).
+constexpr int CONNECT_POLL_SLICE_MS = 100;
+
+// Toggle the blocking mode of a socket. Returns true on success.
+bool setSocketBlocking(socket_t socket_fd, bool blocking)
+{
+#ifdef _WIN32
+ u_long mode = blocking ? 0 : 1;
+ return ::ioctlsocket(socket_fd, FIONBIO, &mode) == 0;
+#else
+ int flags = ::fcntl(socket_fd, F_GETFL, 0);
+ if (flags < 0)
+ {
+ return false;
+ }
+ flags = blocking ? (flags & ~O_NONBLOCK) : (flags | O_NONBLOCK);
+ return ::fcntl(socket_fd, F_SETFL, flags) == 0;
+#endif
+}
+
+// True if the last connect() call indicated that the connection is being established
+// asynchronously (the expected result for a non-blocking socket).
+bool connectInProgress()
+{
+#ifdef _WIN32
+ return ::WSAGetLastError() == WSAEWOULDBLOCK;
+#else
+ return errno == EINPROGRESS;
+#endif
+}
+
+// Waits up to timeout_ms for the socket to become writable (connect resolved).
+// Returns >0 if the socket is ready/has an event, 0 on timeout, <0 on error.
+//
+// poll() is used on both platforms (WSAPoll() on Windows). It avoids select()'s FD_SETSIZE
+// limitation entirely and keeps a single mental model. On Windows this relies on the WSAPoll
+// connect-failure fix introduced in Windows 10 version 2004 / Windows Server 2019: a failed
+// non-blocking connect is reported as (POLLHUP | POLLERR | POLLWRNORM). The caller treats any
+// returned event as "connect resolved" and consults SO_ERROR for the actual outcome, so it does
+// not depend on which particular revents flag is set.
+int waitForSocketWritable(socket_t socket_fd, int timeout_ms)
+{
+#ifdef _WIN32
+ WSAPOLLFD pfd;
+ pfd.fd = socket_fd;
+ pfd.events = POLLWRNORM; // == POLLOUT
+ pfd.revents = 0;
+ return ::WSAPoll(&pfd, 1, timeout_ms);
+#else
+ struct pollfd pfd;
+ pfd.fd = socket_fd;
+ pfd.events = POLLOUT;
+ pfd.revents = 0;
+ return ::poll(&pfd, 1, timeout_ms);
+#endif
+}
+} // namespace
TCPSocket::TCPSocket()
- : socket_fd_(INVALID_SOCKET), state_(SocketState::Invalid), reconnection_time_(std::chrono::seconds(10))
+ : socket_fd_(INVALID_SOCKET)
+ , state_(SocketState::Invalid)
+ , target_state_(SocketState::Invalid)
+ , reconnection_time_(std::chrono::seconds(10))
{
#ifdef _WIN32
WSAData data;
@@ -72,15 +178,75 @@ void TCPSocket::setupOptions()
}
}
-bool TCPSocket::setup(const std::string& host, const int port, const size_t max_num_tries,
- const std::chrono::milliseconds reconnection_time)
+bool TCPSocket::openInterruptible(socket_t socket_fd, struct sockaddr* address, size_t address_len)
+{
+ if (!setSocketBlocking(socket_fd, false))
+ {
+ return false;
+ }
+
+ int connect_res = ::connect(socket_fd, address, static_cast(address_len));
+ bool connected = false;
+ if (connect_res == 0)
+ {
+ // Connected immediately (common for loopback).
+ connected = true;
+ }
+ else if (connectInProgress())
+ {
+ // Poll in short slices until the connect resolves, the OS connect timeout expires,
+ // or a concurrent disconnect() asks us to abort.
+ while (true)
+ {
+ if (isStopRequested())
+ {
+ return false;
+ }
+ int ready = waitForSocketWritable(socket_fd, CONNECT_POLL_SLICE_MS);
+ if (ready < 0)
+ {
+ // poll() error (e.g. the fd was closed by disconnect()).
+ return false;
+ }
+ if (ready == 0)
+ {
+ // Timeout slice elapsed without the connect resolving: re-check stop and keep waiting.
+ continue;
+ }
+ // The socket reported an event: query SO_ERROR to find out whether the connect succeeded.
+ int so_error = 0;
+ socklen_t len = sizeof(so_error);
+ if (::getsockopt(socket_fd, SOL_SOCKET, SO_ERROR, reinterpret_cast(&so_error), &len) < 0)
+ {
+ return false;
+ }
+ connected = (so_error == 0);
+ break;
+ }
+ }
+ else
+ {
+ // Immediate, permanent failure (e.g. connection refused).
+ connected = false;
+ }
+
+ if (connected && !setSocketBlocking(socket_fd, true))
+ {
+ // Could not restore blocking mode; treat the connection as failed.
+ return false;
+ }
+ return connected;
+}
+
+bool TCPSocket::setupInternal(const std::string& host, const int port, const size_t max_num_tries,
+ const std::chrono::milliseconds reconnection_time)
{
// This can be removed once we remove the setReconnectionTime() method
auto reconnection_time_resolved = reconnection_time;
if (reconnection_time_modified_deprecated_)
{
- URCL_LOG_WARN("TCPSocket::setup(): Reconnection time was modified using `setReconnectionTime()` which is "
- "deprecated. Please change your code to set reconnection_time through the `setup()` method "
+ URCL_LOG_WARN("TCPSocket::setupInternal(): Reconnection time was modified using `setReconnectionTime()` which is "
+ "deprecated. Please change your code to set reconnection_time through the `(re)connect()` method "
"directly. The value passed to this function will be ignored.");
reconnection_time_resolved = reconnection_time_;
}
@@ -88,10 +254,9 @@ bool TCPSocket::setup(const std::string& host, const int port, const size_t max_
if (state_ == SocketState::Connected)
return false;
- URCL_LOG_DEBUG("Setting up connection: %s:%d", host.c_str(), port);
+ state_ = SocketState::Connecting;
- // gethostbyname() is deprecated so use getadderinfo() as described in:
- // https://beej.us/guide/bgnet/html/#getaddrinfoprepare-to-launch
+ URCL_LOG_DEBUG("Setting up connection: %s:%d", host.c_str(), port);
const char* host_name = host.empty() ? nullptr : host.c_str();
std::string service = std::to_string(port);
@@ -106,31 +271,41 @@ bool TCPSocket::setup(const std::string& host, const int port, const size_t max_
bool connected = false;
while (!connected)
{
+ if (isStopRequested())
+ return false;
+
if (getaddrinfo(host_name, service.c_str(), &hints, &result) != 0)
{
URCL_LOG_ERROR("Failed to get address for %s:%d", host.c_str(), port);
return false;
}
- // loop through the list of addresses untill we find one that's connectable
+ // loop through the list of addresses until we find one that's connectable
for (struct addrinfo* p = result; p != nullptr; p = p->ai_next)
{
socket_fd_ = ::socket(p->ai_family, p->ai_socktype, p->ai_protocol);
- if (socket_fd_ != -1 && open(socket_fd_, p->ai_addr, p->ai_addrlen))
+ if (socket_fd_ != -1 && openInterruptible(socket_fd_, p->ai_addr, p->ai_addrlen))
{
connected = true;
break;
}
+
+ if (isStopRequested())
+ {
+ freeaddrinfo(result);
+ freeFileDescriptor();
+ return false;
+ }
}
freeaddrinfo(result);
if (!connected)
{
- state_ = SocketState::Invalid;
if (++connect_counter >= max_num_tries && max_num_tries > 0)
{
URCL_LOG_ERROR("Failed to establish connection for %s:%d after %d tries", host.c_str(), port, max_num_tries);
+ freeFileDescriptor();
return false;
}
else
@@ -141,26 +316,116 @@ bool TCPSocket::setup(const std::string& host, const int port, const size_t max_
<< std::chrono::duration_cast>(reconnection_time_resolved).count()
<< " seconds.";
URCL_LOG_ERROR("%s", ss.str().c_str());
- std::this_thread::sleep_for(reconnection_time_resolved);
+ // Sleep in short slices so that a concurrent disconnect() (e.g. from ~RTDEClient or
+ // ~PrimaryClient before joining the reconnect thread) can interrupt the back-off promptly.
+ const auto sleep_slice = std::chrono::milliseconds(100);
+ for (auto slept = std::chrono::milliseconds(0); slept < reconnection_time_resolved && !isStopRequested();
+ slept += sleep_slice)
+ {
+ std::this_thread::sleep_for(sleep_slice);
+ }
+ if (isStopRequested())
+ {
+ freeFileDescriptor();
+ return false;
+ }
}
}
}
setupOptions();
- state_ = SocketState::Connected;
+ // Mark Connected only if no deliberate disconnect() slipped in while we were finishing up; a late
+ // disconnect() must win so we do not advertise a usable socket that was just torn down.
+ SocketState expected = SocketState::Connecting;
+ if (!state_.compare_exchange_strong(expected, SocketState::Connected))
+ {
+ close();
+ return false;
+ }
URCL_LOG_DEBUG("Connection established for %s:%d", host.c_str(), port);
return connected;
}
-void TCPSocket::close()
+bool TCPSocket::connect(const std::string& host, const int port, const size_t max_num_tries,
+ const std::chrono::milliseconds reconnection_time)
+{
+ if (state_ == SocketState::Connected)
+ {
+ URCL_LOG_ERROR("Connect called on a socket that is already connected");
+ return false;
+ }
+ target_state_ = SocketState::Connected;
+ if (!setupInternal(host, port, max_num_tries, reconnection_time))
+ {
+ disconnect();
+ return false;
+ }
+ return true;
+}
+
+bool TCPSocket::reconnect(const std::string& host, const int port, const size_t max_num_tries,
+ const std::chrono::milliseconds reconnection_time)
+{
+ if (state_ != SocketState::LostConnection)
+ {
+ URCL_LOG_ERROR("Reconnect called on a socket that is not in LostConnection state");
+ return false;
+ }
+ setTargetStateUnlessStopRequested(SocketState::Connected);
+ if (!setupInternal(host, port, max_num_tries, reconnection_time))
+ {
+ // If we failed to reconnect, we need to set the target state back to LostConnection so that
+ auto expected_state = SocketState::Connecting;
+ state_.compare_exchange_strong(expected_state, SocketState::LostConnection);
+ return false;
+ }
+ return true;
+}
+
+bool TCPSocket::setTargetStateUnlessStopRequested(SocketState desired)
+{
+ SocketState current_target = target_state_.load();
+ if (current_target == desired)
+ {
+ return true; // already tracking target
+ }
+
+ // Lock-free compare-and-swap: move state_ to `desired` unless a deliberate disconnect()
+ // (Disconnecting/Disconnected) is in effect. This is not an unbounded spin: each iteration
+ // either succeeds, or observes that another thread changed state_ and re-evaluates. We use
+ // compare_exchange_strong so there are no spurious retries; the loop can only re-iterate when a
+ // concurrent writer genuinely changed state_, and the only such writers (a racing disconnect(),
+ // or a paired close()) make a bounded number of writes, so it terminates promptly.
+ while (target_state_ != SocketState::Closed)
+ {
+ if (target_state_.compare_exchange_strong(current_target, desired))
+ {
+ return true; // successfully moved to `desired`
+ }
+ }
+ return false; // a deliberate disconnect() is in effect; state_ left untouched
+}
+
+void TCPSocket::freeFileDescriptor()
{
if (socket_fd_ >= 0)
{
- state_ = SocketState::Closed;
::ur_close(socket_fd_);
socket_fd_ = INVALID_SOCKET;
}
}
+void TCPSocket::close()
+{
+ if (state_ != SocketState::Closed)
+ {
+ // closing overwrites everything, so we do not need to check for stopRequested() here
+ target_state_ = SocketState::Closed;
+ state_ = SocketState::Disconnecting;
+ }
+ freeFileDescriptor();
+ state_ = SocketState::Closed;
+}
+
std::string TCPSocket::getIP() const
{
sockaddr_in name;
@@ -202,7 +467,7 @@ bool TCPSocket::read(uint8_t* buf, const size_t buf_len, size_t& read)
if (res == 0)
{
- state_ = SocketState::Disconnected;
+ state_ = SocketState::LostConnection;
return false;
}
else if (res < 0)
@@ -212,13 +477,13 @@ bool TCPSocket::read(uint8_t* buf, const size_t buf_len, size_t& read)
int code = ::WSAGetLastError();
if (code != WSAETIMEDOUT && code != WSAEWOULDBLOCK)
{
- state_ = SocketState::Disconnected;
+ state_ = SocketState::LostConnection;
}
#else
if (!(errno == EAGAIN || errno == EWOULDBLOCK))
{
// any permanent error should be detected early
- state_ = SocketState::Disconnected;
+ state_ = SocketState::LostConnection;
}
#endif
return false;
diff --git a/src/primary/primary_client.cpp b/src/primary/primary_client.cpp
index 1ae6d4822..68959ecd9 100644
--- a/src/primary/primary_client.cpp
+++ b/src/primary/primary_client.cpp
@@ -67,7 +67,7 @@ PrimaryClient::PrimaryClient(const std::string& robot_ip, [[maybe_unused]] comm:
PrimaryClient::~PrimaryClient()
{
URCL_LOG_INFO("Stopping primary client pipeline");
- pipeline_->stop();
+ stop();
}
void PrimaryClient::start(const size_t max_num_tries, const std::chrono::milliseconds reconnection_time)
@@ -79,8 +79,8 @@ void PrimaryClient::start(const size_t max_num_tries, const std::chrono::millise
void PrimaryClient::stop()
{
+ stream_.disconnect();
pipeline_->stop();
- stream_.close();
}
void PrimaryClient::addPrimaryConsumer(std::shared_ptr> primary_consumer)
diff --git a/src/rtde/rtde_client.cpp b/src/rtde/rtde_client.cpp
index bdac1a733..d47322a2d 100644
--- a/src/rtde/rtde_client.cpp
+++ b/src/rtde/rtde_client.cpp
@@ -87,11 +87,11 @@ RTDEClient::~RTDEClient()
{
prod_->setReconnectionCallback(nullptr);
stop_reconnection_ = true;
+ disconnect();
if (reconnecting_thread_.joinable())
{
reconnecting_thread_.join();
}
- disconnect();
}
bool RTDEClient::init(const size_t max_connection_attempts, const std::chrono::milliseconds reconnection_timeout,
@@ -508,11 +508,8 @@ bool RTDEClient::setupInputs()
void RTDEClient::disconnect()
{
- if (client_state_ > ClientState::UNINITIALIZED)
- {
- stream_.disconnect();
- writer_.stop();
- }
+ stream_.disconnect();
+ writer_.stop();
client_state_ = ClientState::UNINITIALIZED;
prod_->stopProducer();
stopBackgroundRead();
diff --git a/src/ur/dashboard_client_implementation_g5.cpp b/src/ur/dashboard_client_implementation_g5.cpp
index 12a9cfec9..c923d396c 100644
--- a/src/ur/dashboard_client_implementation_g5.cpp
+++ b/src/ur/dashboard_client_implementation_g5.cpp
@@ -220,7 +220,7 @@ bool DashboardClientImplG5::connect(const size_t max_num_tries, const std::chron
TCPSocket::setReceiveTimeout(tv);
try
{
- if (TCPSocket::setup(host_, port_, max_num_tries, reconnection_time))
+ if (TCPSocket::connect(host_, port_, max_num_tries, reconnection_time))
{
URCL_LOG_INFO("%s", read().c_str());
ret_val = true;
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 5582edf8b..99f7e15c7 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -46,6 +46,9 @@ if (INTEGRATION_TESTS)
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
EXTRA_ARGS ${INTEGRATION_TESTS_ROBOT_IP_ARG}
)
+ # Bound this teardown regression test so a hang fails CI fast instead of timing out the job.
+ set_tests_properties(RTDEClientTest.destructor_not_blocked_by_stuck_reconnect_thread
+ PROPERTIES TIMEOUT 60)
if (CHECK_RTDE_DOCS_RECIPE)
find_package(Python3 COMPONENTS Interpreter REQUIRED)
add_custom_target(generate_outputs ALL COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/resources/generate_rtde_outputs.py)
@@ -163,6 +166,17 @@ target_link_libraries(fake_primary_server_tests PRIVATE ur_client_library::urcl
gtest_add_tests(TARGET fake_primary_server_tests
)
+# Robot-free regression test for ~PrimaryClient() not blocking on a stuck
+# reconnect thread. Uses the in-process FakePrimaryServer so it runs without a
+# robot (unlike the INTEGRATION_TESTS-gated primary_client_test_headless).
+add_executable(primary_client_reconnect_tests test_primary_client_reconnect.cpp fake_primary_server.cpp)
+target_link_libraries(primary_client_reconnect_tests PRIVATE ur_client_library::urcl GTest::gtest_main)
+gtest_add_tests(TARGET primary_client_reconnect_tests
+)
+# Bound this teardown regression test so a hang fails CI in ~1 min instead of timing out the job.
+set_tests_properties(PrimaryClientReconnectTest.destructor_not_blocked_by_stuck_reconnect_thread
+ PROPERTIES TIMEOUT 60)
+
add_executable(rtde_data_package_tests test_rtde_data_package.cpp)
@@ -259,6 +273,15 @@ add_executable(tcp_socket_tests test_tcp_socket.cpp)
target_link_libraries(tcp_socket_tests PRIVATE ur_client_library::urcl GTest::gtest_main)
gtest_add_tests(TARGET tcp_socket_tests
)
+# Bound the interruptible-setup regression tests so a hang fails CI fast instead of timing out the job.
+set_tests_properties(TCPSocketTest.setup_interruptible_by_close
+ TCPSocketTest.setup_interruptible_during_blocking_connect
+ PROPERTIES TIMEOUT 60)
+
+add_executable(unique_fd_tests test_unique_fd.cpp)
+target_link_libraries(unique_fd_tests PRIVATE ur_client_library::urcl GTest::gtest_main)
+gtest_add_tests(TARGET unique_fd_tests
+)
add_executable(stream_tests test_stream.cpp)
target_link_libraries(stream_tests PRIVATE ur_client_library::urcl GTest::gtest_main)
diff --git a/tests/test_primary_client_reconnect.cpp b/tests/test_primary_client_reconnect.cpp
new file mode 100644
index 000000000..1032bc19b
--- /dev/null
+++ b/tests/test_primary_client_reconnect.cpp
@@ -0,0 +1,188 @@
+// -- BEGIN LICENSE BLOCK ----------------------------------------------
+// Copyright 2026 Universal Robots A/S
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// * Redistributions of source code must retain the above copyright
+// notice, this list of conditions and the following disclaimer.
+//
+// * Redistributions in binary form must reproduce the above copyright
+// notice, this list of conditions and the following disclaimer in the
+// documentation and/or other materials provided with the distribution.
+//
+// * Neither the name of the {copyright_holder} nor the names of its
+// contributors may be used to endorse or promote products derived from
+// this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+// -- END LICENSE BLOCK ------------------------------------------------
+
+#include
+
+#include
+#include
+#include
+#include
+
+#include
+
+#include "fake_primary_server.h"
+
+using namespace urcl;
+
+// Regression test for ~PrimaryClient() blocking indefinitely when the pipeline's
+// producer thread is stuck in its reconnect loop at teardown time.
+//
+// This is the PrimaryClient counterpart of
+// RTDEClientTest.destructor_not_blocked_by_stuck_reconnect_thread (test_rtde_client.cpp).
+//
+// Root cause: when the robot drops the primary connection, TCPSocket::read()
+// returns false and leaves the socket in SocketState::LostConnection. URProducer's
+// tryGetImpl() then enters its reconnect path: it sleeps an (exponentially
+// growing) backoff and calls stream_.reconnect(), which retries with no upper
+// bound (max_num_tries == 0), sleeping reconnection_time between attempts. If
+// ~PrimaryClient() simply called pipeline_->stop() (which joins the producer
+// thread) without first closing the stream, the join would block for the full
+// reconnect duration — effectively forever for an unreachable robot.
+//
+// Fix (two parts):
+// 1. ~PrimaryClient()/PrimaryClient::stop() call stream_.disconnect() BEFORE
+// joining the pipeline. disconnect() moves the socket into the deliberate-stop
+// state (Disconnecting/Disconnected) and closes it.
+// 2. TCPSocket::setup() honors that state both during its (non-blocking) connect
+// attempt and during the between-attempt wait, so it aborts within ~100 ms
+// regardless of platform. setup() never overwrites the deliberate-stop state,
+// so it cannot be raced away (the bug that hung Windows CI).
+// Together these abort the producer within ~100 ms of the destructor, so the join —
+// and therefore the destructor — returns promptly.
+//
+// Unlike test_primary_client.cpp's robot-dependent fixtures, this test uses the
+// in-process FakePrimaryServer, so it runs in the normal (non-INTEGRATION_TESTS)
+// build and needs no robot.
+TEST(PrimaryClientReconnectTest, destructor_not_blocked_by_stuck_reconnect_thread)
+{
+ comm::INotifier notifier;
+
+ auto server = std::make_unique(primary_interface::UR_PRIMARY_PORT);
+ auto client = std::make_unique("127.0.0.1", notifier);
+
+ // Unlimited reconnect attempts with a large reconnection time: if the fix is
+ // absent, the producer's reconnect path keeps the destructor blocked.
+ const std::chrono::milliseconds large_reconnect_timeout(5000);
+ ASSERT_NO_THROW(client->start(/*max_num_tries=*/0, large_reconnect_timeout));
+ ASSERT_TRUE(server->waitForClient()) << "PrimaryClient never connected to the fake server";
+
+ // Drop the server. The producer's read() fails, the socket transitions to
+ // SocketState::LostConnection, and the producer enters its reconnect loop.
+ server.reset();
+
+ // Give the producer time to detect the drop and reach its reconnect sleep
+ // (initial backoff is 1 s, after which it sleeps inside TCPSocket::setup()).
+ std::this_thread::sleep_for(std::chrono::milliseconds(1500));
+
+ // The destructor must return quickly: disconnect() aborts the producer's connect
+ // attempt/back-off, so the pipeline join completes well under 2 s. Without the fix
+ // this blocks for at least the reconnect timeout (and indefinitely with unlimited
+ // retries against a dead port). Run the destructor on a worker with a watchdog so a
+ // regression fails fast with a clear message instead of hanging the test binary (the
+ // CTest TIMEOUT then reaps it).
+ std::packaged_task teardown([&client]() { client.reset(); });
+ auto teardown_future = teardown.get_future();
+ std::thread teardown_thread(std::move(teardown));
+
+ const auto t0 = std::chrono::steady_clock::now();
+ if (teardown_future.wait_for(std::chrono::seconds(5)) == std::future_status::timeout)
+ {
+ teardown_thread.detach();
+ FAIL() << "~PrimaryClient() did not return within 5 s — the producer reconnect thread was not aborted by "
+ "disconnect()";
+ }
+ teardown_thread.join();
+ const auto elapsed = std::chrono::steady_clock::now() - t0;
+
+ EXPECT_LT(elapsed, std::chrono::seconds(2))
+ << "~PrimaryClient() blocked for " << std::chrono::duration_cast(elapsed).count()
+ << " ms — the producer reconnect thread was not aborted by disconnect()";
+}
+
+// Regression test for the SECOND symptom reported in issue #368: calling
+// PrimaryClient::stop() (the implementation of UrDriver::stopPrimaryClientCommunication())
+// hangs when the producer thread is stuck in its reconnect loop against an unreachable
+// robot.
+//
+// This is distinct from the destructor test above: stop() is a restartable operation, so
+// besides asserting that it returns promptly it must also leave the client in a state where
+// a subsequent start() can reconnect. That exercises the implicit clear of the deliberate-stop
+// state by connect() in URProducer::setupProducer()/PrimaryClient::reconnectStream() — a
+// regression there would not hang teardown but would silently prevent the client from ever
+// reconnecting after a stop().
+TEST(PrimaryClientReconnectTest, stop_not_blocked_by_stuck_reconnect_thread)
+{
+ comm::INotifier notifier;
+
+ auto server = std::make_unique(primary_interface::UR_PRIMARY_PORT);
+ auto client = std::make_unique("127.0.0.1", notifier);
+
+ // Unlimited reconnect attempts with a large reconnection time: if the fix is
+ // absent, the producer's reconnect path keeps stop()'s pipeline join blocked.
+ const std::chrono::milliseconds large_reconnect_timeout(5000);
+ ASSERT_NO_THROW(client->start(/*max_num_tries=*/0, large_reconnect_timeout));
+ ASSERT_TRUE(server->waitForClient()) << "PrimaryClient never connected to the fake server";
+
+ // Drop the server. The producer's read() fails, the socket transitions to
+ // SocketState::LostConnection, and the producer enters its reconnect loop.
+ server.reset();
+
+ // Give the producer time to detect the drop and reach its reconnect sleep
+ // (initial backoff is 1 s, after which it sleeps inside TCPSocket::setup()).
+ std::this_thread::sleep_for(std::chrono::milliseconds(1500));
+
+ // stop() must return quickly: disconnect() aborts the producer's connect attempt/back-off,
+ // so the pipeline join completes well under 2 s. Without the fix this blocks for at least the
+ // reconnect timeout (and indefinitely with unlimited retries against a dead port). Run it on a
+ // worker with a watchdog so a regression fails fast with a clear message instead of hanging the
+ // test binary (the CTest TIMEOUT then reaps it).
+ std::packaged_task stop_task([&client]() { client->stop(); });
+ auto stop_future = stop_task.get_future();
+ std::thread stop_thread(std::move(stop_task));
+
+ const auto t0 = std::chrono::steady_clock::now();
+ if (stop_future.wait_for(std::chrono::seconds(5)) == std::future_status::timeout)
+ {
+ stop_thread.detach();
+ FAIL() << "PrimaryClient::stop() did not return within 5 s — the producer reconnect thread was not aborted by "
+ "disconnect()";
+ }
+ stop_thread.join();
+ const auto elapsed = std::chrono::steady_clock::now() - t0;
+
+ EXPECT_LT(elapsed, std::chrono::seconds(2))
+ << "PrimaryClient::stop() blocked for " << std::chrono::duration_cast(elapsed).count()
+ << " ms — the producer reconnect thread was not aborted by disconnect()";
+
+ // Restart-reuse check: bring up a fresh server and start() again. This must reconnect,
+ // proving that the deliberate-stop state set by stop() was cleared by connect() on restart.
+ auto server2 = std::make_unique(primary_interface::UR_PRIMARY_PORT);
+ ASSERT_NO_THROW(client->start(/*max_num_tries=*/0, large_reconnect_timeout));
+ EXPECT_TRUE(server2->waitForClient(std::chrono::seconds(3))) << "PrimaryClient did not reconnect after "
+ "stop()/start() — the deliberate-stop state set by "
+ "stop() was not cleared "
+ "by connect() on restart";
+}
+
+int main(int argc, char* argv[])
+{
+ ::testing::InitGoogleTest(&argc, argv);
+ return RUN_ALL_TESTS();
+}
diff --git a/tests/test_reverse_interface.cpp b/tests/test_reverse_interface.cpp
index 15f7ca246..7fa0a6c44 100644
--- a/tests/test_reverse_interface.cpp
+++ b/tests/test_reverse_interface.cpp
@@ -76,7 +76,7 @@ class ReverseInterfaceTest : public ::testing::Test
Client(const int& port)
{
std::string host = "127.0.0.1";
- TCPSocket::setup(host, port);
+ TCPSocket::connect(host, port);
timeval tv;
tv.tv_sec = 1;
tv.tv_usec = 0;
diff --git a/tests/test_rtde_client.cpp b/tests/test_rtde_client.cpp
index 98f8c712f..d8fbd95ee 100644
--- a/tests/test_rtde_client.cpp
+++ b/tests/test_rtde_client.cpp
@@ -31,6 +31,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -812,6 +813,91 @@ TEST_F(RTDEClientTest, test_initialization)
EXPECT_GE(std::chrono::duration_cast(elapsed).count(), 20);
}
+// Regression test for the bug where ~RTDEClient() could block indefinitely when
+// the reconnect thread was stuck inside TCPSocket::setup(). Fixed by: (1) calling
+// stream_.disconnect() (followed by RTDEClient::disconnect()) before joining reconnecting_thread_
+// in ~RTDEClient(), and (2) making TCPSocket::setup() abort on the deliberate-stop state,
+// both during the (non-blocking) connect attempt and during the between-attempt wait.
+//
+// See also TCPSocketTest.setup_interruptible_by_close and
+// TCPSocketTest.setup_interruptible_during_blocking_connect in test_tcp_socket.cpp
+// for lower-level unit tests of the same fix that run without INTEGRATION_TESTS.
+TEST_F(RTDEClientTest, destructor_not_blocked_by_stuck_reconnect_thread)
+{
+ // Use a large reconnection timeout so that the blocking window is clearly
+ // observable if the fix is absent (5 s sleep > 2 s assertion threshold).
+ const std::chrono::milliseconds large_reconnect_timeout(5000);
+
+ auto fake_rtde_server = std::make_unique(g_FAKE_RTDE_PORT);
+ // Skip the bootup-timestamp check inside isRobotBooted().
+ fake_rtde_server->setStartTime(std::chrono::steady_clock::now() - std::chrono::seconds(52));
+
+ client_.reset(new rtde_interface::RTDEClient("localhost", notifier_, resources_output_recipe_,
+ resources_input_recipe_, 100, false, g_FAKE_RTDE_PORT));
+ // Attempt init up to 10 times with a short between-attempt sleep to ensure
+ // the RTDE handshake succeeds even in environments where the fake server's
+ // response arrives slightly after the 1-second socket read timeout.
+ bool initialized = false;
+ for (int attempt = 0; attempt < 10 && !initialized; ++attempt)
+ {
+ try
+ {
+ // max_connection_attempts=0 (unlimited): TCPSocket::setup() sleeps
+ // large_reconnect_timeout between every failed connect attempt once the
+ // server is gone. Use a short initialization_timeout for fast retries.
+ client_->init(0, large_reconnect_timeout, 1, std::chrono::milliseconds(50));
+ initialized = true;
+ }
+ catch (const UrException&)
+ {
+ // Recreate the client on each retry to start from a clean state.
+ client_.reset(new rtde_interface::RTDEClient("localhost", notifier_, resources_output_recipe_,
+ resources_input_recipe_, 100, false, g_FAKE_RTDE_PORT));
+ }
+ }
+ if (!initialized)
+ {
+ GTEST_SKIP() << "Could not initialize RTDEClient with the fake server after 10 attempts; "
+ "this test requires a reliably responding RTDE server. "
+ "The TCPSocket-level regression test (TCPSocketTest.setup_interruptible_by_close) "
+ "verifies the underlying fix without a robot.";
+ }
+
+ // start(true) arms the reconnect callback via the background read thread.
+ client_->start(true);
+
+ // Drop the server — the background read thread detects the connection loss,
+ // calls reconnectCallback(), which launches reconnecting_thread_. That thread
+ // enters setupCommunication() -> TCPSocket::setup() and begins sleeping
+ // large_reconnect_timeout between retry attempts.
+ fake_rtde_server.reset();
+
+ // Give the reconnect thread time to reach the wait inside TCPSocket::setup().
+ std::this_thread::sleep_for(std::chrono::milliseconds(500));
+
+ // The destructor must return quickly: disconnect() aborts setup()'s connect/wait,
+ // so the join completes in well under 2 s. Without the fix this would block for
+ // >= large_reconnect_timeout (5 s), or forever with unlimited attempts.
+ // Run the destructor on a worker with a watchdog so a regression fails fast with a
+ // clear message instead of hanging the test binary (the CTest TIMEOUT then reaps it).
+ std::packaged_task teardown([this]() { client_.reset(); });
+ auto teardown_future = teardown.get_future();
+ std::thread teardown_thread(std::move(teardown));
+
+ const auto t0 = std::chrono::steady_clock::now();
+ if (teardown_future.wait_for(std::chrono::seconds(5)) == std::future_status::timeout)
+ {
+ teardown_thread.detach();
+ FAIL() << "~RTDEClient() did not return within 5 s — reconnect thread was not aborted by disconnect()";
+ }
+ teardown_thread.join();
+ const auto elapsed = std::chrono::steady_clock::now() - t0;
+
+ EXPECT_LT(elapsed, std::chrono::seconds(2))
+ << "RTDEClient destructor blocked for " << std::chrono::duration_cast(elapsed).count()
+ << " ms — reconnect thread was not aborted by disconnect()";
+}
+
int main(int argc, char* argv[])
{
::testing::InitGoogleTest(&argc, argv);
diff --git a/tests/test_script_command_interface.cpp b/tests/test_script_command_interface.cpp
index cf1cd0add..63a4f07fd 100644
--- a/tests/test_script_command_interface.cpp
+++ b/tests/test_script_command_interface.cpp
@@ -49,7 +49,7 @@ class ScriptCommandInterfaceTest : public ::testing::Test
Client(const int& port)
{
std::string host = "127.0.0.1";
- TCPSocket::setup(host, port);
+ TCPSocket::connect(host, port);
timeval tv;
tv.tv_sec = 1;
tv.tv_usec = 0;
diff --git a/tests/test_script_sender.cpp b/tests/test_script_sender.cpp
index d800a8bac..bdd97991d 100644
--- a/tests/test_script_sender.cpp
+++ b/tests/test_script_sender.cpp
@@ -43,7 +43,7 @@ class ScriptSenderTest : public ::testing::Test
Client(const int& port)
{
std::string host = "127.0.0.1";
- TCPSocket::setup(host, port);
+ TCPSocket::connect(host, port);
timeval tv;
tv.tv_sec = 1;
tv.tv_usec = 0;
diff --git a/tests/test_tcp_server.cpp b/tests/test_tcp_server.cpp
index 08c8c4fb8..88640d0e5 100644
--- a/tests/test_tcp_server.cpp
+++ b/tests/test_tcp_server.cpp
@@ -34,8 +34,14 @@
#include
#include
#include
+#include
#include
#include
+#ifndef _WIN32
+# include
+# include
+# include
+#endif
#include "test_utils.h"
#include
@@ -52,7 +58,7 @@ class TCPServerTest : public ::testing::Test
Client(const int& port)
{
std::string host = "127.0.0.1";
- TCPSocket::setup(host, port);
+ TCPSocket::connect(host, port);
}
void send(const std::string& text)
@@ -269,7 +275,7 @@ TEST_F(TCPServerTest, check_shutting_down_server_while_listening)
}
EXPECT_FALSE(read_success);
// If the read just would have timeouted, the client state would still be connected.
- EXPECT_EQ(client.getState(), comm::SocketState::Disconnected);
+ EXPECT_EQ(client.getState(), comm::SocketState::LostConnection);
}
TEST_F(TCPServerTest, double_shutdown)
@@ -497,6 +503,186 @@ TEST_F(TCPServerTest, shutdown_during_active_writes)
writer.join();
}
+// Verifies that the server receives data from many clients that all send simultaneously. This
+// exercises the poll() revents loop across many client file descriptors and guards against
+// missed read events when several sockets are readable at once.
+TEST_F(TCPServerTest, receives_from_many_concurrent_clients)
+{
+ comm::TCPServer server(0);
+
+ std::mutex mtx;
+ std::condition_variable cv;
+ std::atomic message_count{ 0 };
+
+ server.setMessageCallback([&](const socket_t, char*, int) {
+ message_count.fetch_add(1);
+ std::lock_guard lk(mtx);
+ cv.notify_all();
+ });
+ server.start();
+
+#ifdef _WIN32
+ // Windows allows a maximum of 64 sockets per process by default.
+ constexpr int num_clients = 50;
+#else
+ constexpr int num_clients = 100;
+#endif
+
+ std::vector> clients;
+ for (int i = 0; i < num_clients; ++i)
+ {
+ clients.push_back(std::make_unique(server.getPort()));
+ }
+
+ // Every client sends a single message concurrently.
+ std::vector senders;
+ for (auto& client : clients)
+ {
+ senders.emplace_back([&client]() { client->send("ping\n"); });
+ }
+ for (auto& t : senders)
+ {
+ t.join();
+ }
+
+ // The server's poll() loop must observe activity on every client FD and deliver all messages.
+ std::unique_lock lk(mtx);
+ EXPECT_TRUE(cv.wait_for(lk, std::chrono::seconds(5), [&]() { return message_count.load() >= num_clients; }));
+ EXPECT_EQ(message_count.load(), num_clients);
+}
+
+#ifndef _WIN32
+// Regression test for the FD_SETSIZE limitation of select(): a client whose accepted socket file
+// descriptor number is >= FD_SETSIZE (1024) must still be serviced normally. This is the exact
+// scenario that occurs when the hosting process (e.g. a JVM) holds many file descriptors. The old
+// select()-based implementation rejected/crashed on such descriptors; poll() handles them.
+TEST_F(TCPServerTest, services_client_with_high_fd_number)
+{
+ // Make sure we are allowed to open more than FD_SETSIZE descriptors; raise the soft limit if
+ // needed and skip the test if the hard limit does not allow it.
+ struct rlimit rl;
+ ASSERT_EQ(getrlimit(RLIMIT_NOFILE, &rl), 0);
+ const rlim_t needed = static_cast(FD_SETSIZE) + 64;
+ if (rl.rlim_cur < needed)
+ {
+ rl.rlim_cur = std::min(needed, rl.rlim_max);
+ if (setrlimit(RLIMIT_NOFILE, &rl) != 0 || rl.rlim_cur < needed)
+ {
+ GTEST_SKIP() << "Cannot raise RLIMIT_NOFILE above FD_SETSIZE; skipping high-fd test.";
+ }
+ }
+
+ // Consume the low-numbered descriptors so that subsequently created sockets are assigned fd
+ // numbers beyond FD_SETSIZE.
+ std::vector fd_hogs;
+ while (true)
+ {
+ int fd = ::open("/dev/null", O_RDONLY);
+ if (fd < 0)
+ {
+ break;
+ }
+ fd_hogs.push_back(fd);
+ if (fd > static_cast(FD_SETSIZE) + 8)
+ {
+ break;
+ }
+ }
+ const bool pushed_past_limit = !fd_hogs.empty() && fd_hogs.back() > static_cast(FD_SETSIZE);
+ if (!pushed_past_limit)
+ {
+ for (int fd : fd_hogs)
+ {
+ ::close(fd);
+ }
+ GTEST_SKIP() << "Could not allocate descriptors beyond FD_SETSIZE; skipping.";
+ }
+
+ TestableTcpServer server(port_);
+ server.start();
+
+ Client client(port_);
+ EXPECT_TRUE(server.waitForConnectionCallback(2000));
+
+ // The server-side accepted client FD should exceed FD_SETSIZE -- the case that breaks select().
+ auto client_fds = server.getClientFDs();
+ ASSERT_FALSE(client_fds.empty());
+ EXPECT_GT(client_fds.back(), static_cast(FD_SETSIZE));
+
+ // Data must flow both ways on the high-numbered descriptor.
+ const std::string message = "high fd message\n";
+ client.send(message);
+ EXPECT_TRUE(server.waitForMessageCallback(2000));
+ EXPECT_EQ(server.getReceivedMessage(), message);
+
+ size_t written;
+ const auto* data = reinterpret_cast(message.c_str());
+ ASSERT_TRUE(server.write(data, message.size(), written));
+ EXPECT_EQ(client.recv(), message);
+
+ // Disconnect must also be detected on the high-numbered descriptor.
+ client.close();
+ EXPECT_TRUE(server.waitForDisconnectionCallback(2000));
+
+ for (int fd : fd_hogs)
+ {
+ ::close(fd);
+ }
+}
+#endif
+
+// White-box regression test: the cached poll set must stay in sync with the connected client set
+// as clients connect and disconnect. rebuildPollfds() runs before the connect/disconnect callbacks
+// fire, so once waitForConnectionCallback()/waitForDisconnectionCallback() returns the poll set is
+// already rebuilt and the invariant can be checked deterministically.
+TEST_F(TCPServerTest, poll_set_tracks_client_set)
+{
+ TestableTcpServer server(port_);
+ server.start();
+ EXPECT_EQ(server.getPollSetSize(), 1u); // listen socket only
+
+ std::vector> clients;
+ for (int i = 0; i < 5; ++i)
+ {
+ clients.push_back(std::make_unique(port_));
+ ASSERT_TRUE(server.waitForConnectionCallback());
+ EXPECT_EQ(server.getPollSetSize(), server.getClientFDs().size() + 1);
+ }
+
+ while (!clients.empty())
+ {
+ clients.back()->close();
+ clients.pop_back();
+ ASSERT_TRUE(server.waitForDisconnectionCallback());
+ EXPECT_EQ(server.getPollSetSize(), server.getClientFDs().size() + 1);
+ }
+ EXPECT_EQ(server.getPollSetSize(), 1u); // back to listen socket only
+}
+
+// Regression test for the snapshot-before-mutate ordering in spin(): a new client connecting must
+// not cause an already-connected client's message that arrives in the same poll cycle to be
+// dropped. If the revents were read after handleConnect() rebuilt the poll set, the existing
+// client's activity would be lost and waitForMessageCallback() would time out.
+TEST_F(TCPServerTest, message_not_lost_when_client_connects_concurrently)
+{
+ TestableTcpServer server(0);
+ server.start();
+ Client existing(server.getPort());
+ ASSERT_TRUE(server.waitForConnectionCallback());
+
+ for (int i = 0; i < 50; ++i)
+ {
+ std::unique_ptr fresh;
+ std::thread connector([&]() { fresh = std::make_unique(server.getPort()); });
+ existing.send("ping\n");
+ ASSERT_TRUE(server.waitForMessageCallback(2000));
+ connector.join();
+ ASSERT_TRUE(server.waitForConnectionCallback(2000));
+ fresh->close();
+ ASSERT_TRUE(server.waitForDisconnectionCallback(2000));
+ }
+}
+
int main(int argc, char* argv[])
{
::testing::InitGoogleTest(&argc, argv);
diff --git a/tests/test_tcp_socket.cpp b/tests/test_tcp_socket.cpp
index bb6bd317a..da27ebe22 100644
--- a/tests/test_tcp_socket.cpp
+++ b/tests/test_tcp_socket.cpp
@@ -30,16 +30,49 @@
#include
#include
-#include
#include
+#include
#include "test_utils.h"
#include
#include
#include "ur_client_library/types.h"
+#ifdef __linux__
+# include
+#endif
+
using namespace urcl;
+#ifdef __linux__
+namespace
+{
+// Counts the process' currently open file descriptors by listing /proc/self/fd. The transient
+// descriptor opened for the directory itself is present in every measurement, so it cancels out
+// when comparing a before/after count.
+size_t countOpenFds()
+{
+ DIR* dir = ::opendir("/proc/self/fd");
+ EXPECT_NE(dir, nullptr) << "Could not open /proc/self/fd";
+ if (dir == nullptr)
+ {
+ return 0;
+ }
+ size_t count = 0;
+ while (struct dirent* entry = ::readdir(dir))
+ {
+ if (std::string(entry->d_name) == "." || std::string(entry->d_name) == "..")
+ {
+ continue;
+ }
+ ++count;
+ }
+ ::closedir(dir);
+ return count;
+}
+} // namespace
+#endif // __linux__
+
class TCPSocketTest : public ::testing::Test
{
protected:
@@ -66,10 +99,40 @@ class TCPSocketTest : public ::testing::Test
ip_ = ip;
}
- bool setup(const size_t max_num_tries = 0,
- const std::chrono::milliseconds reconnection_time = std::chrono::seconds(10))
+ bool connect(const size_t max_num_tries = 0,
+ const std::chrono::milliseconds reconnection_time = std::chrono::seconds(10))
+ {
+ return TCPSocket::connect(ip_, port_, max_num_tries, reconnection_time);
+ }
+
+ bool reconnect(const size_t max_num_tries = 0,
+ const std::chrono::milliseconds reconnection_time = std::chrono::seconds(10))
+ {
+ return TCPSocket::reconnect(ip_, port_, max_num_tries, reconnection_time);
+ }
+
+ bool setTargetStateUnlessStopRequested(comm::SocketState desired)
+ {
+ URCL_LOG_INFO("Setting target state to %s", socketStateToString(desired).c_str());
+ return TCPSocket::setTargetStateUnlessStopRequested(desired);
+ }
+
+ bool isStopRequested() const
+ {
+ return TCPSocket::isStopRequested();
+ }
+
+ void printState()
+ {
+ comm::SocketState state = getState();
+ std::string state_str = socketStateToString(state);
+ std::cout << "Client state: " << state_str << ", stop_requested: " << std::boolalpha << isStopRequested()
+ << std::endl;
+ }
+
+ void disconnect()
{
- return TCPSocket::setup(ip_, port_, max_num_tries, reconnection_time);
+ TCPSocket::close();
}
void setupClientBeforeServer(const size_t max_num_tries = 0,
@@ -107,7 +170,7 @@ class TCPSocketTest : public ::testing::Test
std::chrono::milliseconds reconnection_time = std::chrono::seconds(10))
{
std::string ip = "127.0.0.1";
- TCPSocket::setup(ip, port, max_num_tries, reconnection_time);
+ TCPSocket::connect(ip, port, max_num_tries, reconnection_time);
done_setting_up_client_ = true;
}
};
@@ -125,7 +188,7 @@ TEST_F(TCPSocketTest, socket_state)
EXPECT_EQ(toUnderlying(expected_state), toUnderlying(actual_state));
// Client state should be connected after setup
- client_->setup();
+ client_->connect();
expected_state = comm::SocketState::Connected;
actual_state = client_->getState();
@@ -149,8 +212,8 @@ TEST_F(TCPSocketTest, setup_client_before_server)
// Make sure that the client has tried to connect to the server, before creating the server
std::this_thread::sleep_for(std::chrono::seconds(1));
- // Client state should be invalid as long as the server is not available
- comm::SocketState expected_state = comm::SocketState::Invalid;
+ // Client state should be Connecting while it keeps retrying and the server is not available
+ comm::SocketState expected_state = comm::SocketState::Connecting;
comm::SocketState actual_state = client_->getState();
EXPECT_EQ(toUnderlying(expected_state), toUnderlying(actual_state));
@@ -174,7 +237,7 @@ TEST_F(TCPSocketTest, get_ip)
EXPECT_EQ(expected_ip, actual_ip);
- client_->setup();
+ client_->connect();
expected_ip = "127.0.0.1";
actual_ip = client_->getIP();
@@ -201,7 +264,7 @@ TEST_F(TCPSocketTest, read_on_non_connected_socket)
TEST_F(TCPSocketTest, write_on_connected_socket)
{
- client_->setup();
+ client_->connect();
std::string message = "test message";
const uint8_t* data = reinterpret_cast(message.c_str());
@@ -215,7 +278,7 @@ TEST_F(TCPSocketTest, write_on_connected_socket)
TEST_F(TCPSocketTest, read_on_connected_socket)
{
- client_->setup();
+ client_->connect();
// Make sure the client has connected to the server, before writing to the client
EXPECT_TRUE(server_->waitForConnectionCallback());
@@ -248,7 +311,7 @@ TEST_F(TCPSocketTest, get_socket_fd)
EXPECT_EQ(expected_fd, actual_fd);
- client_->setup();
+ client_->connect();
actual_fd = client_->getSocketFD();
EXPECT_NE(expected_fd, actual_fd);
@@ -261,7 +324,7 @@ TEST_F(TCPSocketTest, get_socket_fd)
TEST_F(TCPSocketTest, receive_timeout)
{
- client_->setup();
+ client_->connect();
timeval tv;
tv.tv_sec = 1;
@@ -277,33 +340,103 @@ TEST_F(TCPSocketTest, receive_timeout)
TEST_F(TCPSocketTest, setup_while_client_is_connected)
{
- client_->setup();
+ client_->connect();
- EXPECT_FALSE(client_->setup());
+ EXPECT_FALSE(client_->connect());
}
TEST_F(TCPSocketTest, connect_non_running_robot)
{
Client client(12321, "127.0.0.1");
auto start = std::chrono::system_clock::now();
- EXPECT_FALSE(client.setup(2, std::chrono::milliseconds(500)));
+ EXPECT_FALSE(client.connect(2, std::chrono::milliseconds(500)));
auto end = std::chrono::system_clock::now();
auto elapsed = end - start;
// This is only a rough estimate, obviously
EXPECT_LT(elapsed, std::chrono::milliseconds(7500));
}
+// Regression test for the bug where TCPSocket::setup() could block the caller
+// indefinitely when the wait between retry attempts was not interruptible.
+//
+// setup() is interrupted by disconnect(), which moves the socket into the deliberate-stop
+// state (Disconnecting/Disconnected) and closes it. setup() never overwrites that state and
+// aborts as soon as it observes it, so a teardown signal cannot be lost by setup()'s internal
+// state updates. This is the path used by ~RTDEClient()/~PrimaryClient() before joining their
+// reconnect threads.
+TEST_F(TCPSocketTest, setup_interruptible_by_close)
+{
+ // Use a port with no listener so every connect attempt fails immediately,
+ // sending setup() into the between-attempt wait.
+ const int unused_port = 12322;
+ const std::chrono::milliseconds large_reconnect_timeout(5000);
+
+ Client client(unused_port, "127.0.0.1");
+
+ // Run setup() with unlimited retries in a background thread.
+ std::thread setup_thread([&client, &large_reconnect_timeout]() {
+ // max_num_tries=0 (unlimited) → setup() waits large_reconnect_timeout after
+ // every failed connect attempt and never exits on its own.
+ client.connect(0, large_reconnect_timeout);
+ });
+
+ // Give the thread time to reach the between-attempt wait inside setup().
+ std::this_thread::sleep_for(std::chrono::milliseconds(300));
+
+ // disconnect() moves the socket to the deliberate-stop state; the sliced wait in setup()
+ // detects it within 100 ms and setup() returns, allowing the thread to finish.
+ const auto t0 = std::chrono::steady_clock::now();
+ client.disconnect();
+
+ setup_thread.join();
+ const auto elapsed = std::chrono::steady_clock::now() - t0;
+
+ // Without the fix, elapsed would be >= large_reconnect_timeout (5 s).
+ EXPECT_LT(elapsed, std::chrono::seconds(2)) << "TCPSocket::setup() was not interrupted by disconnect() within 2 s; "
+ "the between-attempt wait is not interruptible";
+}
+
+// Regression test for issue #368: a reconnect thread blocked inside connect() to a
+// genuinely unreachable host (no SYN-ACK, no RST) must still be abortable. The
+// previous fix only made the between-attempt *sleep* interruptible, not the connect
+// itself, so this case could block for the full OS connect timeout. setup() now uses
+// a non-blocking connect polled in short slices, so disconnect() aborts it promptly.
+TEST_F(TCPSocketTest, setup_interruptible_during_blocking_connect)
+{
+ // 10.255.255.1 is in a private range and is (almost) never routable, so connect()
+ // hangs in SYN retransmit rather than failing fast like a refused localhost port.
+ const int unused_port = 12323;
+ const std::chrono::milliseconds large_reconnect_timeout(5000);
+
+ Client client(unused_port, "10.255.255.1");
+
+ std::thread setup_thread([&client, &large_reconnect_timeout]() { client.connect(0, large_reconnect_timeout); });
+
+ // Give the thread time to enter the (blocking) connect attempt.
+ std::this_thread::sleep_for(std::chrono::milliseconds(500));
+
+ const auto t0 = std::chrono::steady_clock::now();
+ client.disconnect();
+
+ setup_thread.join();
+ const auto elapsed = std::chrono::steady_clock::now() - t0;
+
+ EXPECT_LT(elapsed, std::chrono::seconds(2)) << "TCPSocket::setup() was not interrupted while blocked in connect() "
+ "within 2 s; "
+ "the connect attempt is not interruptible";
+}
+
TEST_F(TCPSocketTest, test_deprecated_reconnection_time_interface)
{
URCL_SILENCE_DEPRECATED_BEGIN
client_->setReconnectionTime(std::chrono::milliseconds(100));
URCL_SILENCE_DEPRECATED_END
- EXPECT_TRUE(client_->setup(2));
+ EXPECT_TRUE(client_->connect(2));
}
TEST_F(TCPSocketTest, test_read_on_socket_abruptly_closed)
{
- client_->setup();
+ client_->connect();
// Make sure the client has connected to the server, before writing to the client
EXPECT_TRUE(server_->waitForConnectionCallback());
@@ -320,7 +453,289 @@ TEST_F(TCPSocketTest, test_read_on_socket_abruptly_closed)
char characters;
size_t read_chars = 0;
EXPECT_FALSE(client_->read((uint8_t*)&characters, 1, read_chars));
- EXPECT_EQ(client_->getState(), comm::SocketState::Disconnected);
+ EXPECT_EQ(client_->getState(), comm::SocketState::LostConnection);
+}
+
+TEST_F(TCPSocketTest, test_socket_client_lifecycle)
+{
+ // After startup a client should be in state Invalid
+ EXPECT_EQ(client_->getState(), comm::SocketState::Invalid);
+ client_->printState();
+
+ client_->connect();
+ EXPECT_EQ(client_->getState(), comm::SocketState::Connected);
+ client_->printState();
+
+ // Make sure the client has connected to the server, before writing to the client
+ EXPECT_TRUE(server_->waitForConnectionCallback());
+
+ std::atomic read_count(0);
+ auto read_fun = [this, &read_count]() {
+ bool read_success = true;
+ uint8_t buffer[64];
+ size_t read_chars = 0;
+ while (read_success)
+ {
+ std::cout << "Reading from socket..." << std::endl;
+ read_success = client_->read(buffer, 64, read_chars);
+ if (read_success)
+ {
+ std::cout << "Read " << read_chars << " characters." << std::endl;
+ read_count.store(read_count.load() + 1);
+ }
+ }
+ };
+
+ // Scenario 1: Client is connected, server is shutdown, client should go into LostConnection
+ // state. Afterwards we should be able to restart the server and reconnect the client.
+ {
+ std::thread read_thread(read_fun);
+
+ std::string send_message = "test message";
+ size_t len = send_message.size();
+ const uint8_t* data = reinterpret_cast(send_message.c_str());
+ size_t written;
+ server_->write(data, len, written);
+
+ ASSERT_NO_THROW(waitFor([&read_count]() { return read_count.load() > 0; }, std::chrono::seconds(1)));
+ std::cout << "Read count: " << read_count.load() << std::endl;
+
+ EXPECT_EQ(client_->getState(), comm::SocketState::Connected);
+ client_->printState();
+
+ std::cout << "Shutting down server..." << std::endl;
+ server_->shutdown();
+
+ read_thread.join();
+ EXPECT_EQ(client_->getState(), comm::SocketState::LostConnection);
+ client_->printState();
+
+ std::cout << "Restarting server..." << std::endl;
+ server_.reset(new TestableTcpServer(60001));
+ server_->start();
+
+ std::cout << "Reconnecting client..." << std::endl;
+ ASSERT_TRUE(client_->connect());
+ client_->printState();
+ }
+
+ // Scenario 2: Deliberate disconnect() while a read is in progress should abort the read and put
+ // the client into Disconnected state. We should be able to reconnect the client afterwards.
+ {
+ std::thread read_thread(read_fun);
+ client_->disconnect();
+ read_thread.join();
+ EXPECT_EQ(client_->getState(), comm::SocketState::Closed);
+ client_->printState();
+
+ client_->connect();
+ EXPECT_EQ(client_->getState(), comm::SocketState::Connected)
+ << "Expected state" << comm::socketStateToString(comm::SocketState::Connected)
+ << ". Got state : " << socketStateToString(client_->getState());
+ client_->printState();
+ }
+
+ // Scenario 3: Server shuts down, client disconnects. Connecting the client will go into a
+ // connection attempt loop. At some point we start the server again and the client should reconnect successfully.
+ // This simulates a client application with a auto-reconnect on read-failure.
+ {
+ std::thread read_thread(read_fun);
+
+ std::cout << "Shutting down server..." << std::endl;
+ server_->shutdown();
+
+ read_thread.join();
+ EXPECT_EQ(client_->getState(), comm::SocketState::LostConnection);
+ client_->printState();
+
+ std::cout << "Starting connection attempt loop..." << std::endl;
+ std::thread connect_thread([this]() {
+ bool result = client_->reconnect(0, std::chrono::milliseconds(100));
+ EXPECT_TRUE(result) << "Client failed to reconnect after server restart";
+ });
+
+ std::this_thread::sleep_for(std::chrono::milliseconds(200));
+ server_.reset(new TestableTcpServer(60001));
+ server_->start();
+
+ connect_thread.join();
+ EXPECT_EQ(client_->getState(), comm::SocketState::Connected)
+ << "Expected state" << comm::socketStateToString(comm::SocketState::Connected)
+ << ". Got state: " << socketStateToString(client_->getState());
+ client_->printState();
+ }
+
+ // Scenario 4: Same as scenario 3, but while reconnecting we do not start the server but
+ // disconnect the client. The client should abort the connection attempt loop and go into Disconnected state.
+ {
+ std::thread read_thread(read_fun);
+
+ std::cout << "Shutting down server..." << std::endl;
+ server_->shutdown();
+
+ read_thread.join();
+ EXPECT_EQ(client_->getState(), comm::SocketState::LostConnection)
+ << "Client state after reconnect: " << socketStateToString(client_->getState());
+ client_->printState();
+
+ std::cout << "Starting connection attempt loop..." << std::endl;
+ std::thread connect_thread([this]() {
+ bool result = client_->reconnect(0, std::chrono::milliseconds(100));
+ EXPECT_FALSE(result) << "Client should have aborted the connection attempt loop";
+ });
+
+ std::this_thread::sleep_for(std::chrono::milliseconds(200));
+ client_->disconnect();
+
+ connect_thread.join();
+ EXPECT_EQ(client_->getState(), comm::SocketState::Closed)
+ << "Client state after reconnect: " << socketStateToString(client_->getState());
+ client_->printState();
+ }
+
+ // Scenario 5: We attempt to connect to a non-reachable server. Disconnecting the client should abort the connection
+ // attempt loop and put the client into Disconnected state.
+ {
+ server_->shutdown();
+ std::thread connect_thread([this]() {
+ bool result = client_->connect(0, std::chrono::milliseconds(100));
+ EXPECT_FALSE(result) << "Client should have aborted the connection attempt loop";
+ });
+
+ std::this_thread::sleep_for(std::chrono::milliseconds(200));
+ client_->disconnect();
+
+ connect_thread.join();
+ EXPECT_EQ(client_->getState(), comm::SocketState::Closed)
+ << "Client state after reconnect: " << socketStateToString(client_->getState());
+ client_->printState();
+ }
+
+ // Scenario 6: Server shuts down, connection lost. Then, client gets disconnected. Server starts
+ // again, reconnect should fail, since it was deliberately disconnected. Connect should work.
+ {
+ server_.reset(new TestableTcpServer(60001));
+ server_->start();
+ client_->connect();
+ std::thread read_thread(read_fun);
+
+ std::cout << "Shutting down server..." << std::endl;
+ server_->shutdown();
+
+ read_thread.join();
+ EXPECT_EQ(client_->getState(), comm::SocketState::LostConnection);
+ client_->printState();
+
+ std::cout << "Disconnecting client..." << std::endl;
+ client_->disconnect();
+ EXPECT_EQ(client_->getState(), comm::SocketState::Closed);
+ client_->printState();
+
+ std::cout << "Starting server..." << std::endl;
+ server_.reset(new TestableTcpServer(60001));
+ server_->start();
+
+ std::cout << "Attempting to reconnect client..." << std::endl;
+ bool result = client_->reconnect(0, std::chrono::milliseconds(100));
+ EXPECT_FALSE(result) << "Client should not have reconnected after deliberate disconnect()";
+ EXPECT_EQ(client_->getState(), comm::SocketState::Closed);
+ client_->printState();
+
+ std::cout << "Attempting to connect client..." << std::endl;
+ result = client_->connect(0, std::chrono::milliseconds(100));
+ EXPECT_TRUE(result) << "Client should have connected after deliberate disconnect()";
+ EXPECT_EQ(client_->getState(), comm::SocketState::Connected);
+ client_->printState();
+ }
+}
+
+TEST_F(TCPSocketTest, test_set_target_state_unless_stop_requested)
+{
+ ASSERT_EQ(client_->getState(), comm::SocketState::Invalid);
+ // Requesting to go to current state should return true
+ ASSERT_TRUE(client_->setTargetStateUnlessStopRequested(comm::SocketState::Invalid));
+ // We should be able to set the target state to Connected, since we are not in a deliberate
+ // disconnect() state
+ ASSERT_TRUE(client_->setTargetStateUnlessStopRequested(comm::SocketState::Connected));
+ // This should not have modified the state of the client, since we are not connected yet
+ ASSERT_EQ(client_->getState(), comm::SocketState::Invalid);
+ client_->connect();
+
+ // Make sure the client has connected to the server, before writing to the client
+ EXPECT_TRUE(server_->waitForConnectionCallback());
+
+ ASSERT_EQ(client_->getState(), comm::SocketState::Connected);
+ // Requesting to go to current state should return true
+ ASSERT_TRUE(client_->setTargetStateUnlessStopRequested(comm::SocketState::Connected));
+
+ client_->disconnect();
+ ASSERT_EQ(client_->getState(), comm::SocketState::Closed);
+ ASSERT_TRUE(client_->isStopRequested());
+ ASSERT_FALSE(client_->setTargetStateUnlessStopRequested(comm::SocketState::Connected));
+ client_->printState();
+}
+
+// Regression test for a file-descriptor leak: each connection attempt creates a new socket
+// descriptor, and a failed attempt must free it. Repeatedly failing to connect must therefore not
+// grow the number of open descriptors.
+TEST_F(TCPSocketTest, no_fd_leak_on_repeated_failed_connects)
+{
+#ifndef __linux__
+ GTEST_SKIP() << "Counting open file descriptors via /proc/self/fd is Linux-only";
+#else
+ // Port with no listener so every attempt fails quickly.
+ const int dead_port = 12321;
+ // A little slack absorbs unrelated descriptors (e.g. logging, DNS caches) that may be opened
+ // lazily on the first attempts; a real leak grows linearly with the iteration count and blows
+ // well past this.
+ const size_t slack = 4;
+
+ // Warm up once so any one-off descriptor allocation happens before we take the baseline.
+ {
+ Client warmup(dead_port, "127.0.0.1");
+ EXPECT_FALSE(warmup.connect(2, std::chrono::milliseconds(20)));
+ }
+
+ const size_t baseline = countOpenFds();
+ for (int i = 0; i < 50; ++i)
+ {
+ Client client(dead_port, "127.0.0.1");
+ EXPECT_FALSE(client.connect(2, std::chrono::milliseconds(20)));
+ }
+ const size_t after = countOpenFds();
+
+ EXPECT_LE(after, baseline + slack) << "Open file descriptors grew from " << baseline << " to " << after
+ << " over 50 failed connection attempts; a descriptor is leaking";
+#endif // __linux__
+}
+
+// Regression test targeting the retry loop specifically: with unlimited retries the socket keeps
+// creating new descriptors between attempts. Letting it retry several times and then disconnecting
+// must not leave leaked descriptors behind.
+TEST_F(TCPSocketTest, no_fd_leak_in_retry_loop)
+{
+#ifndef __linux__
+ GTEST_SKIP() << "Counting open file descriptors via /proc/self/fd is Linux-only";
+#else
+ const int dead_port = 12321;
+ const size_t slack = 4;
+
+ const size_t baseline = countOpenFds();
+
+ Client client(dead_port, "127.0.0.1");
+ // Unlimited retries with a short back-off, so the loop runs many iterations while we wait.
+ std::thread connect_thread([&client]() { client.connect(0, std::chrono::milliseconds(50)); });
+
+ // Give the retry loop time to run through a good number of failed attempts.
+ std::this_thread::sleep_for(std::chrono::milliseconds(500));
+
+ client.disconnect();
+ connect_thread.join();
+
+ const size_t after = countOpenFds();
+ EXPECT_LE(after, baseline + slack) << "Open file descriptors grew from " << baseline << " to " << after
+ << " while retrying to connect; the retry loop is leaking descriptors";
+#endif // __linux__
}
int main(int argc, char* argv[])
diff --git a/tests/test_trajectory_point_interface.cpp b/tests/test_trajectory_point_interface.cpp
index 5e0356c96..f648eac78 100644
--- a/tests/test_trajectory_point_interface.cpp
+++ b/tests/test_trajectory_point_interface.cpp
@@ -79,7 +79,7 @@ class TrajectoryPointInterfaceTest : public ::testing::Test
Client(const int& port)
{
std::string host = "127.0.0.1";
- TCPSocket::setup(host, port);
+ TCPSocket::connect(host, port);
timeval tv;
tv.tv_sec = 1;
tv.tv_usec = 0;
diff --git a/tests/test_unique_fd.cpp b/tests/test_unique_fd.cpp
new file mode 100644
index 000000000..028c3cd22
--- /dev/null
+++ b/tests/test_unique_fd.cpp
@@ -0,0 +1,148 @@
+// -- BEGIN LICENSE BLOCK ----------------------------------------------
+// Copyright 2026 Universal Robots A/S
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// * Redistributions of source code must retain the above copyright
+// notice, this list of conditions and the following disclaimer.
+//
+// * Redistributions in binary form must reproduce the above copyright
+// notice, this list of conditions and the following disclaimer in the
+// documentation and/or other materials provided with the distribution.
+//
+// * Neither the name of the {copyright_holder} nor the names of its
+// contributors may be used to endorse or promote products derived from
+// this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+// -- END LICENSE BLOCK ------------------------------------------------
+
+#include
+#include
+
+#include
+
+using namespace urcl::comm;
+
+// UniqueFd is a thin RAII wrapper around a socket descriptor. On POSIX platforms a socket
+// descriptor is an ordinary file descriptor, so we can allocate real descriptors with dup() and
+// verify that they get closed by querying fcntl(F_GETFD), which fails with EBADF once the
+// descriptor is closed. On Windows sockets are not fcntl-able, so the closing-behaviour tests are
+// POSIX-only; the ownership semantics are identical across platforms.
+#ifndef _WIN32
+# include
+# include
+
+namespace
+{
+// Allocates a fresh, valid descriptor for testing by duplicating stdin.
+socket_t makeFd()
+{
+ socket_t fd = ::dup(0);
+ EXPECT_GE(fd, 0) << "Could not allocate a test file descriptor";
+ return fd;
+}
+
+bool isFdOpen(socket_t fd)
+{
+ return ::fcntl(fd, F_GETFD) != -1;
+}
+} // namespace
+
+TEST(UniqueFdTest, default_constructed_is_invalid)
+{
+ UniqueFd fd;
+ EXPECT_EQ(fd.get(), INVALID_SOCKET);
+}
+
+TEST(UniqueFdTest, reset_adopts_descriptor)
+{
+ socket_t raw = makeFd();
+ UniqueFd fd;
+ fd.reset(raw);
+ EXPECT_EQ(fd.get(), raw);
+ EXPECT_TRUE(isFdOpen(raw));
+}
+
+TEST(UniqueFdTest, reset_with_new_fd_closes_previous)
+{
+ socket_t first = makeFd();
+ socket_t second = makeFd();
+
+ UniqueFd fd;
+ fd.reset(first);
+ fd.reset(second);
+
+ // The previously held descriptor must have been closed, and the new one adopted.
+ EXPECT_FALSE(isFdOpen(first));
+ EXPECT_TRUE(isFdOpen(second));
+ EXPECT_EQ(fd.get(), second);
+}
+
+TEST(UniqueFdTest, reset_without_argument_closes_and_invalidates)
+{
+ socket_t raw = makeFd();
+
+ UniqueFd fd;
+ fd.reset(raw);
+ fd.reset();
+
+ EXPECT_FALSE(isFdOpen(raw));
+ EXPECT_EQ(fd.get(), INVALID_SOCKET);
+}
+
+TEST(UniqueFdTest, reset_with_same_fd_is_a_noop)
+{
+ socket_t raw = makeFd();
+
+ UniqueFd fd;
+ fd.reset(raw);
+ // Resetting with the descriptor we already hold must not close it.
+ fd.reset(raw);
+
+ EXPECT_TRUE(isFdOpen(raw));
+ EXPECT_EQ(fd.get(), raw);
+}
+
+TEST(UniqueFdTest, destructor_closes_descriptor)
+{
+ socket_t raw = makeFd();
+ {
+ UniqueFd fd;
+ fd.reset(raw);
+ EXPECT_TRUE(isFdOpen(raw));
+ }
+ EXPECT_FALSE(isFdOpen(raw));
+}
+
+TEST(UniqueFdTest, destructor_on_invalid_is_safe)
+{
+ // Destroying a UniqueFd that never held a descriptor must not attempt to close anything.
+ UniqueFd fd;
+ SUCCEED();
+}
+#endif // _WIN32
+
+TEST(UniqueFdTest, is_non_copyable)
+{
+ static_assert(!std::is_copy_constructible::value, "UniqueFd must not be copy constructible");
+ static_assert(!std::is_copy_assignable::value, "UniqueFd must not be copy assignable");
+ SUCCEED();
+}
+
+int main(int argc, char* argv[])
+{
+ ::testing::InitGoogleTest(&argc, argv);
+ return RUN_ALL_TESTS();
+}
diff --git a/tests/test_utils.h b/tests/test_utils.h
index 31fc11022..08273b51d 100644
--- a/tests/test_utils.h
+++ b/tests/test_utils.h
@@ -165,6 +165,11 @@ class TestableTcpServer : public urcl::comm::TCPServer
return client_fds_;
}
+ size_t getPollSetSize()
+ {
+ return TCPServer::getPollSetSize();
+ }
+
private:
std::vector client_fds_;
std::condition_variable connect_cv_;