Skip to content

Commit 8d5ade3

Browse files
author
gophergogo
committed
Cover connections_active/total across three concurrent accepts (#219)
Pin the accept-side stat counters with an integration test that opens three raw TCP clients, asserts connections_active grew by 3 (baseline-relative, since the SetUp readiness probe can also land as an accepted connection), then drains them via server shutdown and asserts connections_active returns to zero while connections_total stays at baseline+3. Shutdown-driven drain rather than client-initiated close is deliberate: a raw TCP client that never sent any application bytes does not reliably drive the server-side HTTP filter chain to observe the peer FIN, and coupling this stat test to that behavior would make it flaky. The drain path is dispatcher- driven and deterministic. Also adds a small waitForConnectionStatsIdle helper so SetUp can let any in-flight probe accepts settle before a test snapshots the baseline.
1 parent 049d091 commit 8d5ade3

1 file changed

Lines changed: 120 additions & 0 deletions

File tree

tests/integration/test_mcp_server_connection_lifecycle.cc

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)