@@ -119,6 +119,16 @@ class McpServerConnectionLifecycleTest : public ::testing::Test {
119119
120120 ASSERT_TRUE (waitForListenerReady (port_, 5s))
121121 << " Server did not begin accepting on port " << port_;
122+
123+ // The readiness probe in waitForListenerReady opens a TCP
124+ // connection and closes it. The kernel accepts the SYN and queues
125+ // the connection; the server-side onNewConnection may run slightly
126+ // after this point and bump connections_total / connections_active.
127+ // Wait for the counters to stop moving so tests that take a
128+ // baseline snapshot see a stable starting value. Polling for
129+ // "unchanged for one tick" is cheaper than guessing a sleep long
130+ // enough to cover the slowest machine.
131+ waitForConnectionStatsIdle (200ms, 2s);
122132 }
123133
124134 void TearDown () override {
@@ -210,11 +220,121 @@ class McpServerConnectionLifecycleTest : public ::testing::Test {
210220 return false ;
211221 }
212222
223+ // Poll until connections_total has not changed for `quiet_for` or
224+ // the overall budget elapses. Used after SetUp to let any in-flight
225+ // probe accepts land before a test snapshots the baseline.
226+ void waitForConnectionStatsIdle (std::chrono::milliseconds quiet_for,
227+ std::chrono::milliseconds budget) {
228+ const auto deadline = std::chrono::steady_clock::now () + budget;
229+ uint64_t last = server_->getServerStats ().connections_total .load ();
230+ auto stable_since = std::chrono::steady_clock::now ();
231+ while (std::chrono::steady_clock::now () < deadline) {
232+ std::this_thread::sleep_for (10ms);
233+ uint64_t now_total = server_->getServerStats ().connections_total .load ();
234+ if (now_total != last) {
235+ last = now_total;
236+ stable_since = std::chrono::steady_clock::now ();
237+ continue ;
238+ }
239+ if (std::chrono::steady_clock::now () - stable_since >= quiet_for) {
240+ return ;
241+ }
242+ }
243+ }
244+
245+ // Wait for connections_active to match `expected` within the budget.
246+ // Poll because the counter is updated on the dispatcher thread in
247+ // response to async events.
248+ bool waitForActiveConnections (uint64_t expected,
249+ std::chrono::milliseconds budget) {
250+ const auto deadline = std::chrono::steady_clock::now () + budget;
251+ while (std::chrono::steady_clock::now () < deadline) {
252+ if (server_->getServerStats ().connections_active .load () == expected) {
253+ return true ;
254+ }
255+ std::this_thread::sleep_for (10ms);
256+ }
257+ return server_->getServerStats ().connections_active .load () == expected;
258+ }
259+
213260 uint16_t port_{0 };
214261 std::unique_ptr<server::McpServer> server_;
215262 std::thread server_thread_;
216263};
217264
265+ // connections_active / connections_total are maintained correctly for
266+ // server-accepted TCP sockets. ConnectionImpl does not raise Connected
267+ // for server sockets -- it is only raised on the client-side
268+ // connect-completion path -- so the increment can't piggyback on the
269+ // lifecycle adapter's Connected branch. Instead it lives in
270+ // McpServer::onNewConnection. This test pins that: the counter has to
271+ // go up on connect and back down on close, symmetrically, across
272+ // multiple concurrent connections.
273+ TEST_F (McpServerConnectionLifecycleTest, AcceptedConnectionsAreCounted) {
274+ // Snapshot the baseline rather than assume zero. waitForListenerReady
275+ // in SetUp opens and closes a probe socket, and the kernel may queue
276+ // that as a server-accepted connection whose close does not reach
277+ // onConnectionLifecycleEvent within the test budget (ConnectionImpl
278+ // on a server socket registers the read-EOF listener only once the
279+ // filter chain is wired up). Measuring deltas against whatever the
280+ // server has already observed keeps the test honest without depending
281+ // on the probe's teardown.
282+ const auto & stats = server_->getServerStats ();
283+ const uint64_t base_active = stats.connections_active .load ();
284+ const uint64_t base_total = stats.connections_total .load ();
285+
286+ auto a = openClient ();
287+ auto b = openClient ();
288+ auto c = openClient ();
289+ ASSERT_NE (a, nullptr );
290+ ASSERT_NE (b, nullptr );
291+ ASSERT_NE (c, nullptr );
292+
293+ // onNewConnection runs on the server's dispatcher thread in response
294+ // to the listener's accept; poll until each of the three accepts has
295+ // been counted (relative to the baseline).
296+ ASSERT_TRUE (waitForActiveConnections (base_active + 3u , 2s))
297+ << " connections_active never reached baseline+3 after three "
298+ " client connects" ;
299+ EXPECT_GE (stats.connections_total .load (), base_total + 3u );
300+
301+ // Shut the server down. The drain path closes each live connection
302+ // on the dispatcher thread (LocalClose), which runs through
303+ // onConnectionLifecycleEvent and decrements connections_active. If
304+ // the pre-fix code path were still in place the counter would not
305+ // have been incremented on accept, so the drain's N decrements would
306+ // wrap it into UINT64_MAX; observing it return cleanly to zero is a
307+ // direct proof that increment and decrement agree.
308+ //
309+ // We use the drain path rather than client-initiated close because a
310+ // raw TCP client that never sent any application bytes may not drive
311+ // the server-side ConnectionImpl to observe the FIN promptly -- that
312+ // is an HTTP filter behavior we don't want to couple the stats test
313+ // to. The drain is deterministic and dispatcher-driven.
314+ server_->shutdown ();
315+ if (server_thread_.joinable ()) {
316+ server_thread_.join ();
317+ }
318+
319+ EXPECT_EQ (stats.connections_active .load (), 0u )
320+ << " connections_active did not return to zero after server drain" ;
321+
322+ // total is monotonic -- never decremented -- and must reflect every
323+ // accept we observed, regardless of what closed.
324+ EXPECT_GE (stats.connections_total .load (), base_total + 3u );
325+
326+ // Drop the clients so the test process releases their fds cleanly.
327+ // The server is already gone, so their reads would return EOF.
328+ a->close ();
329+ a.reset ();
330+ b->close ();
331+ b.reset ();
332+ c->close ();
333+ c.reset ();
334+
335+ // TearDown's shutdown() will no-op on the already-stopped server.
336+ }
337+
218338// Repeated connect/close cycles don't wedge the listener. Each cycle
219339// relies on the lifecycle adapter firing RemoteClose on the dispatcher
220340// and erasing the connection from active_connections_ /
0 commit comments