Skip to content

Commit 2125d55

Browse files
committed
Enhance TCPServer with poll set management
- Introduced a cached poll set to optimize socket polling, reducing allocations during steady state. - Added methods to rebuild the poll set on client connections and disconnections. - Implemented tests to ensure the poll set remains in sync with connected clients and to prevent message loss during concurrent client connections. - Updated spin() method to utilize the cached poll set for improved performance.
1 parent 4747e5f commit 2125d55

4 files changed

Lines changed: 137 additions & 24 deletions

File tree

include/ur_client_library/comm/tcp_server.h

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040

4141
#include "ur_client_library/comm/socket_t.h"
4242

43+
#ifndef _WIN32
44+
# include <poll.h>
45+
#endif
46+
4347
namespace urcl
4448
{
4549
namespace comm
@@ -198,6 +202,14 @@ class TCPServer
198202
return port_;
199203
}
200204

205+
protected:
206+
// Test hook: number of file descriptors currently in the cached poll set
207+
// (listen socket + one per connected client).
208+
size_t getPollSetSize() const
209+
{
210+
return pollfds_.size();
211+
}
212+
201213
private:
202214
void init();
203215
void bind(const size_t max_num_tries, const std::chrono::milliseconds reconnection_time);
@@ -217,6 +229,15 @@ class TCPServer
217229
//! Runs spin() as long as keep_running_ is set to true.
218230
void worker();
219231

232+
//! Rebuilds the cached poll set (pollfds_) from the listen socket and client_fds_.
233+
void rebuildPollfds();
234+
235+
// Number of client slots the poll set reserves up front when the client count is unbounded
236+
// (max_clients_allowed_ == 0). Bounded servers reserve their exact limit instead. This is just
237+
// headroom so the first few connects (incl. transient overlap during a robot reconnect) don't
238+
// reallocate; the vector still grows geometrically beyond it.
239+
static constexpr uint32_t DEFAULT_RESERVED_CLIENTS = 8;
240+
220241
std::atomic<bool> keep_running_{ false };
221242
std::thread worker_thread_;
222243

@@ -225,6 +246,18 @@ class TCPServer
225246

226247
uint32_t max_clients_allowed_;
227248
std::vector<socket_t> client_fds_;
249+
250+
#ifdef _WIN32
251+
using PollFd = WSAPOLLFD;
252+
#else
253+
using PollFd = struct pollfd;
254+
#endif
255+
// Cached poll set: index 0 is the listen socket, the rest mirror client_fds_. Only touched by
256+
// the worker thread (seeded in start() before the thread launches, rebuilt in
257+
// handleConnect()/handleDisconnect(), cleared in shutdown() after the worker is joined), so
258+
// spin() can poll() on it directly without per-iteration allocation or extra locking.
259+
std::vector<PollFd> pollfds_;
260+
228261
std::mutex clients_mutex_;
229262
std::mutex message_mutex_;
230263
std::mutex listen_fd_mutex_;

src/comm/tcp_server.cpp

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ void TCPServer::shutdown()
140140
}
141141
// This will effectively deactivate the disconnection handler.
142142
client_fds_.clear();
143+
// Worker thread has been joined above, so the poll set is no longer in use; keep it consistent.
144+
pollfds_.clear();
143145
ur_close(shutdown_socket);
144146
ur_close(listen_fd_);
145147
listen_fd_ = INVALID_SOCKET;
@@ -225,6 +227,7 @@ void TCPServer::handleConnect()
225227
if (client_fds_.size() < max_clients_allowed_ || max_clients_allowed_ == 0)
226228
{
227229
client_fds_.push_back(client_fd);
230+
rebuildPollfds();
228231
accepted = true;
229232
}
230233
else
@@ -244,28 +247,32 @@ void TCPServer::handleConnect()
244247
}
245248
}
246249

247-
void TCPServer::spin()
250+
void TCPServer::rebuildPollfds()
248251
{
249-
// Build the poll set fresh each iteration from the listen socket plus all currently connected
250-
// clients. poll() is used on both platforms (WSAPoll() on Windows) because it has no
251-
// FD_SETSIZE limit on file descriptor numbers, unlike select(). This matters when the hosting
252-
// process holds many file descriptors (e.g. a JVM), pushing socket FDs past FD_SETSIZE (1024).
253-
std::vector<struct pollfd> pollfds;
254-
pollfds.push_back({ static_cast<socket_t>(listen_fd_), POLLIN, 0 });
252+
// clear() + push_back() reuses the vector's existing capacity, so this does not allocate once
253+
// the set has reached a given size.
254+
pollfds_.clear();
255+
pollfds_.push_back({ static_cast<socket_t>(listen_fd_), POLLIN, 0 });
256+
for (const auto& client_fd : client_fds_)
255257
{
256-
std::lock_guard<std::mutex> lk(clients_mutex_);
257-
for (const auto& client_fd : client_fds_)
258-
{
259-
pollfds.push_back({ client_fd, POLLIN, 0 });
260-
}
258+
pollfds_.push_back({ client_fd, POLLIN, 0 });
261259
}
260+
}
262261

262+
void TCPServer::spin()
263+
{
264+
// Poll the cached set (pollfds_) directly. It is kept in sync with client_fds_ via
265+
// rebuildPollfds() on every connect/disconnect, so no allocation happens here in steady state.
266+
// poll() is used on both platforms (WSAPoll() on Windows) because it has no FD_SETSIZE limit on
267+
// file descriptor numbers, unlike select(). This matters when the hosting process holds many
268+
// file descriptors (e.g. a JVM), pushing socket FDs past FD_SETSIZE (1024).
269+
//
263270
// Block for up to 1 s waiting for activity on any socket. A shutdown wakes this immediately by
264271
// connecting to the listen socket (see shutdown()).
265272
#ifdef _WIN32
266-
int ready = ::WSAPoll(pollfds.data(), static_cast<ULONG>(pollfds.size()), 1000);
273+
int ready = ::WSAPoll(pollfds_.data(), static_cast<ULONG>(pollfds_.size()), 1000);
267274
#else
268-
int ready = ::poll(pollfds.data(), pollfds.size(), 1000);
275+
int ready = ::poll(pollfds_.data(), pollfds_.size(), 1000);
269276
#endif
270277
if (ready < 0)
271278
{
@@ -279,24 +286,29 @@ void TCPServer::spin()
279286
return;
280287
}
281288

282-
if (pollfds[0].revents & POLLIN)
283-
{
284-
URCL_LOG_DEBUG("Activity on listen FD %d", (int)listen_fd_);
285-
handleConnect();
286-
}
289+
// Snapshot the poll results into locals BEFORE handleConnect()/handleDisconnect() rebuild
290+
// pollfds_, otherwise activity on an existing client that arrives in the same poll cycle as a
291+
// new connection would be lost.
292+
const bool listen_activity = (pollfds_[0].revents & POLLIN) != 0;
287293

288294
std::vector<socket_t> disconnected_clients;
289295
std::vector<socket_t> client_fds_with_activity;
290296

291-
// pollfds[0] is the listen socket; client entries start at index 1.
292-
for (size_t i = 1; i < pollfds.size(); ++i)
297+
// pollfds_[0] is the listen socket; client entries start at index 1.
298+
for (size_t i = 1; i < pollfds_.size(); ++i)
293299
{
294-
if (pollfds[i].revents & (POLLIN | POLLHUP | POLLERR))
300+
if (pollfds_[i].revents & (POLLIN | POLLHUP | POLLERR))
295301
{
296-
URCL_LOG_DEBUG("Activity on client FD %d", (int)pollfds[i].fd);
297-
client_fds_with_activity.push_back(static_cast<socket_t>(pollfds[i].fd));
302+
URCL_LOG_DEBUG("Activity on client FD %d", (int)pollfds_[i].fd);
303+
client_fds_with_activity.push_back(static_cast<socket_t>(pollfds_[i].fd));
298304
}
299305
}
306+
307+
if (listen_activity)
308+
{
309+
URCL_LOG_DEBUG("Activity on listen FD %d", (int)listen_fd_);
310+
handleConnect();
311+
}
300312
// We handle client activity outside the clients_mutex_ lock to avoid holding it during potentially slow I/O and
301313
// message callbacks.
302314
// The clients_mutex_ lock is only needed to protect the client_fds_ vector, but once we have copied the FDs with
@@ -329,6 +341,7 @@ void TCPServer::handleDisconnect(const socket_t fd)
329341
break;
330342
}
331343
}
344+
rebuildPollfds();
332345
}
333346

334347
{
@@ -394,6 +407,16 @@ void TCPServer::start()
394407
{
395408
URCL_LOG_DEBUG("Starting worker thread");
396409
keep_running_ = true;
410+
// Seed the poll set with the listen socket before the worker thread starts polling it. Reserve
411+
// room for the listen socket plus the expected number of clients up front so the first
412+
// connections don't reallocate. A bounded server reserves its exact client limit; an unbounded
413+
// one (max_clients_allowed_ == 0) reserves DEFAULT_RESERVED_CLIENTS as headroom.
414+
{
415+
std::lock_guard<std::mutex> lk(clients_mutex_);
416+
const uint32_t expected_clients = max_clients_allowed_ > 0 ? max_clients_allowed_ : DEFAULT_RESERVED_CLIENTS;
417+
pollfds_.reserve(static_cast<size_t>(expected_clients) + 1);
418+
rebuildPollfds();
419+
}
397420
worker_thread_ = std::thread(&TCPServer::worker, this);
398421
}
399422

tests/test_tcp_server.cpp

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,58 @@ TEST_F(TCPServerTest, services_client_with_high_fd_number)
631631
}
632632
#endif
633633

634+
// White-box regression test: the cached poll set must stay in sync with the connected client set
635+
// as clients connect and disconnect. rebuildPollfds() runs before the connect/disconnect callbacks
636+
// fire, so once waitForConnectionCallback()/waitForDisconnectionCallback() returns the poll set is
637+
// already rebuilt and the invariant can be checked deterministically.
638+
TEST_F(TCPServerTest, poll_set_tracks_client_set)
639+
{
640+
TestableTcpServer server(port_);
641+
server.start();
642+
EXPECT_EQ(server.getPollSetSize(), 1u); // listen socket only
643+
644+
std::vector<std::unique_ptr<Client>> clients;
645+
for (int i = 0; i < 5; ++i)
646+
{
647+
clients.push_back(std::make_unique<Client>(port_));
648+
ASSERT_TRUE(server.waitForConnectionCallback());
649+
EXPECT_EQ(server.getPollSetSize(), server.getClientFDs().size() + 1);
650+
}
651+
652+
while (!clients.empty())
653+
{
654+
clients.back()->close();
655+
clients.pop_back();
656+
ASSERT_TRUE(server.waitForDisconnectionCallback());
657+
EXPECT_EQ(server.getPollSetSize(), server.getClientFDs().size() + 1);
658+
}
659+
EXPECT_EQ(server.getPollSetSize(), 1u); // back to listen socket only
660+
}
661+
662+
// Regression test for the snapshot-before-mutate ordering in spin(): a new client connecting must
663+
// not cause an already-connected client's message that arrives in the same poll cycle to be
664+
// dropped. If the revents were read after handleConnect() rebuilt the poll set, the existing
665+
// client's activity would be lost and waitForMessageCallback() would time out.
666+
TEST_F(TCPServerTest, message_not_lost_when_client_connects_concurrently)
667+
{
668+
TestableTcpServer server(0);
669+
server.start();
670+
Client existing(server.getPort());
671+
ASSERT_TRUE(server.waitForConnectionCallback());
672+
673+
for (int i = 0; i < 50; ++i)
674+
{
675+
std::unique_ptr<Client> fresh;
676+
std::thread connector([&]() { fresh = std::make_unique<Client>(server.getPort()); });
677+
existing.send("ping\n");
678+
ASSERT_TRUE(server.waitForMessageCallback(2000));
679+
connector.join();
680+
ASSERT_TRUE(server.waitForConnectionCallback(2000));
681+
fresh->close();
682+
ASSERT_TRUE(server.waitForDisconnectionCallback(2000));
683+
}
684+
}
685+
634686
int main(int argc, char* argv[])
635687
{
636688
::testing::InitGoogleTest(&argc, argv);

tests/test_utils.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,11 @@ class TestableTcpServer : public urcl::comm::TCPServer
165165
return client_fds_;
166166
}
167167

168+
size_t getPollSetSize()
169+
{
170+
return TCPServer::getPollSetSize();
171+
}
172+
168173
private:
169174
std::vector<socket_t> client_fds_;
170175
std::condition_variable connect_cv_;

0 commit comments

Comments
 (0)