2929#include < gtest/gtest.h>
3030
3131#include < atomic>
32+ #include < cerrno>
3233#include < cmath>
3334#include < filesystem>
3435#include < fstream>
@@ -71,14 +72,22 @@ using cuopt::mathematical_optimization::testing::GrpcTestLogCapture;
7172
7273namespace {
7374
74- // =============================================================================
75- // Server Process Manager
76- // =============================================================================
75+ // GRPC_INTEGRATION_TEST
76+ // `-- cuopt_grpc_server parent (pid_, leads process group pgid_ == pid_)
77+ // `-- GPU worker (inherits pgid_)
78+ //
79+ // The server is forked into its own process group so stop() can signal the whole
80+ // group and guarantee the GPU worker child is never left orphaned.
7781
7882class ServerProcess {
7983 public:
80- ServerProcess () : pid_(-1 ), port_(0 ) {}
81- ~ServerProcess () { stop (); }
84+ ServerProcess () : pid_(-1 ), pgid_(-1 ), port_(0 ) {}
85+ ServerProcess (const ServerProcess&) = delete ;
86+ ServerProcess& operator =(const ServerProcess&) = delete ;
87+ ~ServerProcess ()
88+ {
89+ if (!stop ()) { std::cerr << " Failed to clean up test server process group\n " ; }
90+ }
8291
8392 void set_tls_config (const std::string& root_certs,
8493 const std::string& client_cert = " " ,
@@ -91,21 +100,27 @@ class ServerProcess {
91100
92101 bool start (int port, const std::vector<std::string>& extra_args = {})
93102 {
94- port_ = port;
103+ if (pid_ > 0 ) {
104+ std::cerr << " Cannot reuse a ServerProcess while it still owns a process lifecycle\n " ;
105+ return false ;
106+ }
95107
96108 std::string server_path = find_server_binary ();
97109 if (server_path.empty ()) {
98110 std::cerr << " Could not find cuopt_grpc_server binary\n " ;
99111 return false ;
100112 }
101113
102- pid_ = fork ();
114+ port_ = port;
115+ pid_ = fork ();
103116 if (pid_ < 0 ) {
104117 std::cerr << " fork() failed\n " ;
105118 return false ;
106119 }
107120
108121 if (pid_ == 0 ) {
122+ setpgid (0 , 0 ); // child leads its own group; parent mirrors this below
123+
109124 std::vector<const char *> args;
110125 args.push_back (server_path.c_str ());
111126 args.push_back (" --port" );
@@ -131,30 +146,46 @@ class ServerProcess {
131146 _exit (127 );
132147 }
133148
134- return wait_for_ready (15000 );
149+ // Mirror the child's setpgid() so the group is established regardless of which
150+ // process runs first; the child is a fresh fork, so its group id is its pid.
151+ setpgid (pid_, pid_);
152+ pgid_ = pid_;
153+
154+ if (!wait_for_ready (15000 )) {
155+ if (!stop ()) { std::cerr << " Failed to clean up server after readiness failure\n " ; }
156+ return false ;
157+ }
158+
159+ return true ;
135160 }
136161
137- void stop ()
162+ bool stop ()
138163 {
139- if (pid_ > 0 ) {
140- kill (pid_, SIGTERM );
141-
142- int status;
143- int wait_ms = 0 ;
144- while (wait_ms < 5000 ) {
145- int ret = waitpid (pid_, &status, WNOHANG );
146- if (ret != 0 ) break ;
147- std::this_thread::sleep_for (std::chrono::milliseconds (100 ));
148- wait_ms += 100 ;
149- }
150-
151- if (waitpid (pid_, &status, WNOHANG ) == 0 ) {
152- kill (pid_, SIGKILL );
153- waitpid (pid_, &status, 0 );
164+ if (pid_ <= 0 ) return true ;
165+
166+ // Ask the whole group (server + GPU worker) to exit gracefully, reaping the
167+ // server parent so its zombie does not keep the group id alive.
168+ kill (-pgid_, SIGTERM );
169+ bool parent_reaped = reap_parent (std::chrono::seconds (5 ));
170+
171+ // Escalate to SIGKILL if the parent ignored SIGTERM, or if any group member
172+ // (e.g. the GPU worker) is still alive once the parent is gone.
173+ if (!parent_reaped || !wait_for_group_exit (std::chrono::seconds (5 ))) {
174+ kill (-pgid_, SIGKILL );
175+ if (!parent_reaped) {
176+ int status = 0 ;
177+ while (waitpid (pid_, &status, 0 ) < 0 && errno == EINTR ) {}
154178 }
179+ }
155180
156- pid_ = -1 ;
181+ // Only consider the lifecycle over once the full group is confirmed gone.
182+ if (!wait_for_group_exit (std::chrono::seconds (5 ))) {
183+ std::cerr << " Server process group " << pgid_ << " did not exit\n " ;
184+ return false ;
157185 }
186+
187+ clear_lifecycle_state ();
188+ return true ;
158189 }
159190
160191 int port () const { return port_; }
@@ -172,6 +203,40 @@ class ServerProcess {
172203 }
173204
174205 private:
206+ void clear_lifecycle_state ()
207+ {
208+ pid_ = -1 ;
209+ pgid_ = -1 ;
210+ port_ = 0 ;
211+ }
212+
213+ // Reap the server parent, returning true once it has exited (within timeout).
214+ bool reap_parent (std::chrono::milliseconds timeout)
215+ {
216+ auto deadline = std::chrono::steady_clock::now () + timeout;
217+ while (true ) {
218+ int status = 0 ;
219+ pid_t ret = waitpid (pid_, &status, WNOHANG );
220+ if (ret == pid_) return true ;
221+ if (ret < 0 && errno == ECHILD ) return true ; // already reaped elsewhere
222+ if (ret < 0 && errno == EINTR ) continue ;
223+ if (std::chrono::steady_clock::now () >= deadline) return false ;
224+ std::this_thread::sleep_for (std::chrono::milliseconds (10 ));
225+ }
226+ }
227+
228+ // True once no process remains in the group. The parent must already be reaped,
229+ // otherwise its zombie keeps the group id alive.
230+ bool wait_for_group_exit (std::chrono::milliseconds timeout) const
231+ {
232+ auto deadline = std::chrono::steady_clock::now () + timeout;
233+ while (true ) {
234+ if (kill (-pgid_, 0 ) != 0 && errno == ESRCH ) return true ;
235+ if (std::chrono::steady_clock::now () >= deadline) return false ;
236+ std::this_thread::sleep_for (std::chrono::milliseconds (10 ));
237+ }
238+ }
239+
175240 std::string find_in_path (const std::string& name)
176241 {
177242 const char * path_env = std::getenv (" PATH" );
@@ -246,8 +311,8 @@ class ServerProcess {
246311 grpc_client_t client (config);
247312 if (client.connect ()) { return true ; }
248313
249- int status;
250- if (waitpid (pid_, &status, WNOHANG ) != 0 ) {
314+ int status = 0 ;
315+ if (waitpid (pid_, &status, WNOHANG ) == pid_ ) {
251316 std::cerr << " Server process died during startup\n " ;
252317 return false ;
253318 }
@@ -257,6 +322,7 @@ class ServerProcess {
257322 }
258323
259324 pid_t pid_;
325+ pid_t pgid_;
260326 int port_;
261327 std::string tls_root_certs_;
262328 std::string tls_client_cert_;
@@ -516,7 +582,7 @@ class DefaultServerTests : public GrpcIntegrationTestBase {
516582
517583 static void TearDownTestSuite ()
518584 {
519- if (s_server_) s_server_->stop ();
585+ if (s_server_) EXPECT_TRUE ( s_server_->stop () );
520586 s_server_.reset ();
521587 }
522588
@@ -1083,7 +1149,7 @@ class ChunkedUploadTests : public GrpcIntegrationTestBase {
10831149
10841150 static void TearDownTestSuite ()
10851151 {
1086- if (s_server_) s_server_->stop ();
1152+ if (s_server_) EXPECT_TRUE ( s_server_->stop () );
10871153 s_server_.reset ();
10881154 }
10891155
@@ -1399,7 +1465,7 @@ class PathSelectionTests : public GrpcIntegrationTestBase {
13991465
14001466 static void TearDownTestSuite ()
14011467 {
1402- if (s_server_) s_server_->stop ();
1468+ if (s_server_) EXPECT_TRUE ( s_server_->stop () );
14031469 s_server_.reset ();
14041470 }
14051471
@@ -1583,7 +1649,7 @@ TEST_F(PathSelectionTests, UnaryUploadMIPWithPathLogging)
15831649class ErrorRecoveryTests : public GrpcIntegrationTestBase {
15841650 protected:
15851651 void SetUp () override { port_ = get_test_port (); }
1586- void TearDown () override { server_.stop (); }
1652+ void TearDown () override { EXPECT_TRUE ( server_.stop () ); }
15871653
15881654 bool start_server (const std::vector<std::string>& extra_args = {})
15891655 {
@@ -1602,7 +1668,7 @@ TEST_F(ErrorRecoveryTests, ClientReconnectsAfterServerRestart)
16021668 auto status_before = client->check_status (" test-job" );
16031669 EXPECT_TRUE (status_before.success );
16041670
1605- server_.stop ();
1671+ ASSERT_TRUE ( server_.stop () );
16061672 EXPECT_FALSE (server_.is_running ());
16071673
16081674 auto status_down = client->check_status (" test-job" );
@@ -1630,7 +1696,7 @@ TEST_F(ErrorRecoveryTests, ClientHandlesServerCrashDuringSolve)
16301696 ASSERT_TRUE (submit_result.success );
16311697
16321698 std::this_thread::sleep_for (std::chrono::milliseconds (500 ));
1633- server_.stop ();
1699+ EXPECT_TRUE ( server_.stop () );
16341700
16351701 auto status_result = client->check_status (submit_result.job_id );
16361702 EXPECT_FALSE (status_result.success );
@@ -1694,7 +1760,7 @@ TEST_F(ErrorRecoveryTests, ChunkedUploadAfterServerRestart)
16941760 auto result1 = client->solve_mip (problem, settings, false );
16951761 EXPECT_TRUE (result1.success ) << result1.error_message ;
16961762
1697- server_.stop ();
1763+ ASSERT_TRUE ( server_.stop () );
16981764 ASSERT_TRUE (start_server ({" --max-message-mb" , " 256" }));
16991765
17001766 auto client2 = create_client (config);
@@ -1745,7 +1811,7 @@ class TlsServerTests : public GrpcIntegrationTestBase {
17451811
17461812 static void TearDownTestSuite ()
17471813 {
1748- if (s_server_) s_server_->stop ();
1814+ if (s_server_) EXPECT_TRUE ( s_server_->stop () );
17491815 s_server_.reset ();
17501816 }
17511817
@@ -1846,7 +1912,7 @@ class MtlsServerTests : public GrpcIntegrationTestBase {
18461912
18471913 static void TearDownTestSuite ()
18481914 {
1849- if (s_server_) s_server_->stop ();
1915+ if (s_server_) EXPECT_TRUE ( s_server_->stop () );
18501916 s_server_.reset ();
18511917 }
18521918
@@ -1917,7 +1983,7 @@ class ChunkValidationTests : public GrpcIntegrationTestBase {
19171983
19181984 static void TearDownTestSuite ()
19191985 {
1920- if (s_server_) s_server_->stop ();
1986+ if (s_server_) EXPECT_TRUE ( s_server_->stop () );
19211987 s_server_.reset ();
19221988 }
19231989
0 commit comments