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,33 +146,42 @@ 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- }
164+ if (pid_ <= 0 ) return true ;
165+
166+ // Ask the whole group (server + GPU worker) to exit, escalating to SIGKILL if
167+ // the server does not honor SIGTERM within the timeout.
168+ kill (-pgid_, SIGTERM );
169+ if (!reap_parent (std::chrono::seconds (5 ))) {
170+ kill (-pgid_, SIGKILL );
171+ int status = 0 ;
172+ while (waitpid (pid_, &status, 0 ) < 0 && errno == EINTR ) {}
173+ }
150174
151- if (waitpid (pid_, &status, WNOHANG ) == 0 ) {
152- kill (pid_, SIGKILL );
153- waitpid (pid_, &status, 0 );
154- }
175+ // With the server reaped, the group is gone once the GPU worker exits too.
176+ bool group_exited = wait_for_group_exit (std::chrono::seconds (5 ));
177+ if (!group_exited) { std::cerr << " Server process group " << pgid_ << " did not exit\n " ; }
155178
156- pid_ = - 1 ;
157- }
179+ clear_lifecycle_state () ;
180+ return group_exited;
158181 }
159182
160183 int port () const { return port_; }
184+ pid_t verified_process_group_id () const { return pgid_; }
161185
162186 bool is_running () const
163187 {
@@ -172,6 +196,40 @@ class ServerProcess {
172196 }
173197
174198 private:
199+ void clear_lifecycle_state ()
200+ {
201+ pid_ = -1 ;
202+ pgid_ = -1 ;
203+ port_ = 0 ;
204+ }
205+
206+ // Reap the server parent, returning true once it has exited (within timeout).
207+ bool reap_parent (std::chrono::milliseconds timeout)
208+ {
209+ auto deadline = std::chrono::steady_clock::now () + timeout;
210+ while (true ) {
211+ int status = 0 ;
212+ pid_t ret = waitpid (pid_, &status, WNOHANG );
213+ if (ret == pid_) return true ;
214+ if (ret < 0 && errno == ECHILD ) return true ; // already reaped elsewhere
215+ if (ret < 0 && errno == EINTR ) continue ;
216+ if (std::chrono::steady_clock::now () >= deadline) return false ;
217+ std::this_thread::sleep_for (std::chrono::milliseconds (10 ));
218+ }
219+ }
220+
221+ // True once no process remains in the group. The parent must already be reaped,
222+ // otherwise its zombie keeps the group id alive.
223+ bool wait_for_group_exit (std::chrono::milliseconds timeout) const
224+ {
225+ auto deadline = std::chrono::steady_clock::now () + timeout;
226+ while (true ) {
227+ if (kill (-pgid_, 0 ) != 0 && errno == ESRCH ) return true ;
228+ if (std::chrono::steady_clock::now () >= deadline) return false ;
229+ std::this_thread::sleep_for (std::chrono::milliseconds (10 ));
230+ }
231+ }
232+
175233 std::string find_in_path (const std::string& name)
176234 {
177235 const char * path_env = std::getenv (" PATH" );
@@ -246,8 +304,8 @@ class ServerProcess {
246304 grpc_client_t client (config);
247305 if (client.connect ()) { return true ; }
248306
249- int status;
250- if (waitpid (pid_, &status, WNOHANG ) != 0 ) {
307+ int status = 0 ;
308+ if (waitpid (pid_, &status, WNOHANG ) == pid_ ) {
251309 std::cerr << " Server process died during startup\n " ;
252310 return false ;
253311 }
@@ -257,6 +315,7 @@ class ServerProcess {
257315 }
258316
259317 pid_t pid_;
318+ pid_t pgid_;
260319 int port_;
261320 std::string tls_root_certs_;
262321 std::string tls_client_cert_;
@@ -516,7 +575,7 @@ class DefaultServerTests : public GrpcIntegrationTestBase {
516575
517576 static void TearDownTestSuite ()
518577 {
519- if (s_server_) s_server_->stop ();
578+ if (s_server_) EXPECT_TRUE ( s_server_->stop () );
520579 s_server_.reset ();
521580 }
522581
@@ -1083,7 +1142,7 @@ class ChunkedUploadTests : public GrpcIntegrationTestBase {
10831142
10841143 static void TearDownTestSuite ()
10851144 {
1086- if (s_server_) s_server_->stop ();
1145+ if (s_server_) EXPECT_TRUE ( s_server_->stop () );
10871146 s_server_.reset ();
10881147 }
10891148
@@ -1399,7 +1458,7 @@ class PathSelectionTests : public GrpcIntegrationTestBase {
13991458
14001459 static void TearDownTestSuite ()
14011460 {
1402- if (s_server_) s_server_->stop ();
1461+ if (s_server_) EXPECT_TRUE ( s_server_->stop () );
14031462 s_server_.reset ();
14041463 }
14051464
@@ -1576,14 +1635,51 @@ TEST_F(PathSelectionTests, UnaryUploadMIPWithPathLogging)
15761635 << log_capture.get_server_logs ();
15771636}
15781637
1638+ // =============================================================================
1639+ // Server Process Lifecycle Tests
1640+ // =============================================================================
1641+
1642+ class ServerProcessLifecycleTests : public GrpcIntegrationTestBase {
1643+ protected:
1644+ void SetUp () override { port_ = get_test_port (); }
1645+ void TearDown () override { EXPECT_TRUE (server_.stop ()); }
1646+
1647+ ServerProcess server_;
1648+ };
1649+
1650+ ::testing::AssertionResult VerifiedProcessGroupIsGone (pid_t pgid)
1651+ {
1652+ errno = 0 ;
1653+ if (kill (-pgid, 0 ) < 0 && errno == ESRCH ) { return ::testing::AssertionSuccess (); }
1654+ return ::testing::AssertionFailure ()
1655+ << " Verified server process group " << pgid << " still exists (errno " << errno << " )" ;
1656+ }
1657+
1658+ TEST_F (ServerProcessLifecycleTests, IdleServerStopCleansProcessGroup)
1659+ {
1660+ ASSERT_TRUE (server_.start (port_));
1661+ pid_t server_pgid = server_.verified_process_group_id ();
1662+ ASSERT_GT (server_pgid, 0 );
1663+ EXPECT_TRUE (server_.stop ());
1664+ EXPECT_FALSE (server_.is_running ());
1665+ EXPECT_TRUE (VerifiedProcessGroupIsGone (server_pgid));
1666+ }
1667+
1668+ TEST_F (ServerProcessLifecycleTests, StopIsIdempotent)
1669+ {
1670+ ASSERT_TRUE (server_.start (port_));
1671+ ASSERT_TRUE (server_.stop ());
1672+ EXPECT_TRUE (server_.stop ());
1673+ }
1674+
15791675// =============================================================================
15801676// Error Recovery Tests (per-test server lifecycle)
15811677// =============================================================================
15821678
15831679class ErrorRecoveryTests : public GrpcIntegrationTestBase {
15841680 protected:
15851681 void SetUp () override { port_ = get_test_port (); }
1586- void TearDown () override { server_.stop (); }
1682+ void TearDown () override { EXPECT_TRUE ( server_.stop () ); }
15871683
15881684 bool start_server (const std::vector<std::string>& extra_args = {})
15891685 {
@@ -1602,7 +1698,7 @@ TEST_F(ErrorRecoveryTests, ClientReconnectsAfterServerRestart)
16021698 auto status_before = client->check_status (" test-job" );
16031699 EXPECT_TRUE (status_before.success );
16041700
1605- server_.stop ();
1701+ ASSERT_TRUE ( server_.stop () );
16061702 EXPECT_FALSE (server_.is_running ());
16071703
16081704 auto status_down = client->check_status (" test-job" );
@@ -1617,6 +1713,8 @@ TEST_F(ErrorRecoveryTests, ClientReconnectsAfterServerRestart)
16171713TEST_F (ErrorRecoveryTests, ClientHandlesServerCrashDuringSolve)
16181714{
16191715 ASSERT_TRUE (start_server ());
1716+ pid_t server_pgid = server_.verified_process_group_id ();
1717+ ASSERT_GT (server_pgid, 0 );
16201718 auto client = create_client ();
16211719 ASSERT_NE (client, nullptr );
16221720
@@ -1630,7 +1728,8 @@ TEST_F(ErrorRecoveryTests, ClientHandlesServerCrashDuringSolve)
16301728 ASSERT_TRUE (submit_result.success );
16311729
16321730 std::this_thread::sleep_for (std::chrono::milliseconds (500 ));
1633- server_.stop ();
1731+ EXPECT_TRUE (server_.stop ()) << " Active-solve shutdown must remove the server process group" ;
1732+ EXPECT_TRUE (VerifiedProcessGroupIsGone (server_pgid));
16341733
16351734 auto status_result = client->check_status (submit_result.job_id );
16361735 EXPECT_FALSE (status_result.success );
@@ -1694,7 +1793,7 @@ TEST_F(ErrorRecoveryTests, ChunkedUploadAfterServerRestart)
16941793 auto result1 = client->solve_mip (problem, settings, false );
16951794 EXPECT_TRUE (result1.success ) << result1.error_message ;
16961795
1697- server_.stop ();
1796+ ASSERT_TRUE ( server_.stop () );
16981797 ASSERT_TRUE (start_server ({" --max-message-mb" , " 256" }));
16991798
17001799 auto client2 = create_client (config);
@@ -1745,7 +1844,7 @@ class TlsServerTests : public GrpcIntegrationTestBase {
17451844
17461845 static void TearDownTestSuite ()
17471846 {
1748- if (s_server_) s_server_->stop ();
1847+ if (s_server_) EXPECT_TRUE ( s_server_->stop () );
17491848 s_server_.reset ();
17501849 }
17511850
@@ -1846,7 +1945,7 @@ class MtlsServerTests : public GrpcIntegrationTestBase {
18461945
18471946 static void TearDownTestSuite ()
18481947 {
1849- if (s_server_) s_server_->stop ();
1948+ if (s_server_) EXPECT_TRUE ( s_server_->stop () );
18501949 s_server_.reset ();
18511950 }
18521951
@@ -1917,7 +2016,7 @@ class ChunkValidationTests : public GrpcIntegrationTestBase {
19172016
19182017 static void TearDownTestSuite ()
19192018 {
1920- if (s_server_) s_server_->stop ();
2019+ if (s_server_) EXPECT_TRUE ( s_server_->stop () );
19212020 s_server_.reset ();
19222021 }
19232022
0 commit comments