diff --git a/cpp/tests/linear_programming/grpc/CMakeLists.txt b/cpp/tests/linear_programming/grpc/CMakeLists.txt index aadbf23df3..60ce550b84 100644 --- a/cpp/tests/linear_programming/grpc/CMakeLists.txt +++ b/cpp/tests/linear_programming/grpc/CMakeLists.txt @@ -99,7 +99,6 @@ target_include_directories(GRPC_INTEGRATION_TEST PRIVATE "${CUOPT_SOURCE_DIR}/include" "${CUOPT_SOURCE_DIR}/src/grpc" - "${CUOPT_SOURCE_DIR}/src/grpc/client" "${CUOPT_TEST_DIR}" "${CMAKE_BINARY_DIR}" # For generated protobuf headers ) diff --git a/cpp/tests/linear_programming/grpc/grpc_integration_test.cpp b/cpp/tests/linear_programming/grpc/grpc_integration_test.cpp index a3aafc3936..938667c64e 100644 --- a/cpp/tests/linear_programming/grpc/grpc_integration_test.cpp +++ b/cpp/tests/linear_programming/grpc/grpc_integration_test.cpp @@ -5,23 +5,24 @@ /** * @file grpc_integration_test.cpp - * @brief Integration tests for gRPC client-server communication + * @brief Integration tests for gRPC server end-to-end behaviour. * - * Tests are organized into shared-server fixtures to minimize server startup overhead. - * Total target runtime: ~3 minutes. + * All tests use the public blocking API: solve_lp_remote() / solve_mip_remote(). + * Server address is passed via CUOPT_REMOTE_HOST / CUOPT_REMOTE_PORT env vars + * that each fixture configures in SetUp() and clears in TearDown(). * * Fixture layout: * NoServerTests - Tests that don't need a server - * DefaultServerTests - Shared server with default config (~21 tests) - * ChunkedUploadTests - Shared server with --max-message-mb 256 (4 tests) - * PathSelectionTests - Shared server with --max-message-bytes 4096 --verbose (4 tests) - * ErrorRecoveryTests - Per-test server lifecycle (4 tests) - * TlsServerTests - Shared TLS server (2 tests) - * MtlsServerTests - Shared mTLS server (2 tests) + * DefaultServerTests - Shared server with default config + * ChunkedUploadTests - Shared server with small CUOPT_MAX_MESSAGE_BYTES + * ErrorRecoveryTests - Per-test server lifecycle + * TlsServerTests - Shared TLS server + * MtlsServerTests - Shared mTLS server + * ChunkValidationTests - Raw gRPC stub; malformed chunk rejection * * Environment variables: - * CUOPT_GRPC_SERVER_PATH - Path to cuopt_grpc_server binary - * CUOPT_TEST_PORT_BASE - Base port for test servers (default: 19000) + * CUOPT_GRPC_SERVER_PATH - Path to cuopt_grpc_server binary + * CUOPT_TEST_PORT_BASE - Base port for test servers (default: 19000) * RAPIDS_DATASET_ROOT_DIR - Path to test datasets */ @@ -43,16 +44,12 @@ #include #include #include +#include #include -#include "grpc_client.hpp" - -#include "grpc_test_log_capture.hpp" #include #include -#include "grpc_service_mapper.hpp" - #include #include #include @@ -69,7 +66,6 @@ #include using namespace cuopt::mathematical_optimization; -using cuopt::mathematical_optimization::testing::GrpcTestLogCapture; namespace { @@ -81,8 +77,7 @@ namespace { // group. Killing the server parent orphans its GPU worker, so this test process // also marks itself a subreaper (PR_SET_CHILD_SUBREAPER): the orphan reparents // here and stop() reaps the entire group, even inside a container whose PID 1 -// does not reap. Polling kill(-pgid_, 0) instead is not enough -- it counts an -// unreaped zombie as a live member and can never observe a grandchild's death. +// does not reap. class ServerProcess { public: @@ -116,8 +111,6 @@ class ServerProcess { return false; } - // Reparent any process orphaned by killing the server (i.e. the GPU worker) - // onto this test process so stop() can reap it without relying on init. prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0); port_ = port; @@ -128,7 +121,7 @@ class ServerProcess { } if (pid_ == 0) { - setpgid(0, 0); // child leads its own group; parent mirrors this below + setpgid(0, 0); std::vector args; args.push_back(server_path.c_str()); @@ -155,8 +148,6 @@ class ServerProcess { _exit(127); } - // Mirror the child's setpgid() so the group is established regardless of which - // process runs first; the child is a fresh fork, so its group id is its pid. setpgid(pid_, pid_); pgid_ = pid_; @@ -172,9 +163,6 @@ class ServerProcess { { if (pid_ <= 0) return true; - // Ask the whole group (server + GPU worker) to exit gracefully, then reap - // every member. Killing the server parent can orphan a mid-solve worker; - // because we are a subreaper it reparents here and reap_group() collects it. kill(-pgid_, SIGTERM); if (!reap_group(std::chrono::seconds(15))) { kill(-pgid_, SIGKILL); @@ -210,20 +198,15 @@ class ServerProcess { port_ = 0; } - // Reap every member of the server's process group, returning true once the - // group is fully drained. start() makes this process a subreaper, so a worker - // orphaned when the server parent dies reparents here and is reaped too -- - // first the parent, then (once its death reparents the worker) the worker. bool reap_group(std::chrono::milliseconds timeout) { auto deadline = std::chrono::steady_clock::now() + timeout; while (true) { int status = 0; pid_t ret = waitpid(-pgid_, &status, WNOHANG); - if (ret > 0) continue; // reaped a member; keep draining + if (ret > 0) continue; if (ret < 0 && errno == EINTR) continue; - if (ret < 0 && errno == ECHILD) return true; // no members left - // ret == 0: members remain but none have exited yet. + if (ret < 0 && errno == ECHILD) return true; if (std::chrono::steady_clock::now() >= deadline) return false; std::this_thread::sleep_for(std::chrono::milliseconds(10)); } @@ -284,32 +267,32 @@ class ServerProcess { { auto start = std::chrono::steady_clock::now(); + std::shared_ptr creds; + if (!tls_root_certs_.empty()) { + grpc::SslCredentialsOptions ssl_opts; + ssl_opts.pem_root_certs = tls_root_certs_; + ssl_opts.pem_cert_chain = tls_client_cert_; + ssl_opts.pem_private_key = tls_client_key_; + creds = grpc::SslCredentials(ssl_opts); + } else { + creds = grpc::InsecureChannelCredentials(); + } + auto channel = grpc::CreateChannel("localhost:" + std::to_string(port_), std::move(creds)); + while (true) { auto elapsed = std::chrono::duration_cast( std::chrono::steady_clock::now() - start); if (elapsed.count() >= timeout_ms) { return false; } - grpc_client_config_t config; - config.server_address = "localhost:" + std::to_string(port_); - - if (!tls_root_certs_.empty()) { - config.enable_tls = true; - config.tls_root_certs = tls_root_certs_; - config.tls_client_cert = tls_client_cert_; - config.tls_client_key = tls_client_key_; - } - - grpc_client_t client(config); - if (client.connect()) { return true; } + auto deadline = std::chrono::system_clock::now() + std::chrono::milliseconds(200); + if (channel->WaitForConnected(deadline)) { return true; } int status = 0; if (waitpid(pid_, &status, WNOHANG) == pid_) { std::cerr << "Server process died during startup\n"; return false; } - - std::this_thread::sleep_for(std::chrono::milliseconds(200)); } } @@ -343,7 +326,6 @@ bool ensure_test_certs() { if (g_tls_certs_ready) return true; - // Check for CI-provided certs const char* cert_folder = std::getenv("CERT_FOLDER"); if (cert_folder) { g_tls_certs_dir = cert_folder; @@ -408,16 +390,21 @@ std::string read_file_contents(const std::string& path) class GrpcIntegrationTestBase : public ::testing::Test { protected: - std::unique_ptr create_client(grpc_client_config_t config = {}) + void set_remote_host(int port) { - config.server_address = "localhost:" + std::to_string(port_); - config.poll_interval_ms = 100; - - if (config.timeout_seconds == 3600) { config.timeout_seconds = 60; } + setenv("CUOPT_REMOTE_HOST", "localhost", 1); + setenv("CUOPT_REMOTE_PORT", std::to_string(port).c_str(), 1); + } - auto client = std::make_unique(config); - if (!client->connect()) { return nullptr; } - return client; + void clear_remote_env() + { + unsetenv("CUOPT_REMOTE_HOST"); + unsetenv("CUOPT_REMOTE_PORT"); + unsetenv("CUOPT_TLS_ENABLED"); + unsetenv("CUOPT_TLS_ROOT_CERT"); + unsetenv("CUOPT_TLS_CLIENT_CERT"); + unsetenv("CUOPT_TLS_CLIENT_KEY"); + unsetenv("CUOPT_MAX_MESSAGE_BYTES"); } std::string get_test_data_path(const std::string& subdir, const std::string& filename) @@ -437,9 +424,6 @@ class GrpcIntegrationTestBase : public ::testing::Test { return get_test_data_path("mip", filename); } - // Load a problem from disk, dispatching by file extension to read_mps() - // for .mps/.qps (with optional .gz/.bz2) and read_lp() for .lp (with - // optional .gz/.bz2). See io::read() in parser.hpp. cpu_optimization_problem_t load_problem_from_file(const std::string& path) { auto mps_data = cuopt::mathematical_optimization::io::read(path); @@ -465,18 +449,6 @@ End return problem; } - void wait_for_job_done(grpc_client_t* client, const std::string& job_id, int max_seconds = 30) - { - for (int i = 0; i < max_seconds * 2; ++i) { - auto status = client->check_status(job_id); - if (status.status == job_status_t::COMPLETED || status.status == job_status_t::FAILED || - status.status == job_status_t::CANCELLED) { - return; - } - std::this_thread::sleep_for(std::chrono::milliseconds(500)); - } - } - int port_ = 0; }; @@ -486,76 +458,21 @@ End class NoServerTests : public GrpcIntegrationTestBase { protected: - void SetUp() override { port_ = get_test_port(); } + void SetUp() override + { + port_ = get_test_port(); + set_remote_host(port_); + } + void TearDown() override { clear_remote_env(); } }; TEST_F(NoServerTests, ConnectToNonexistentServer) { - grpc_client_config_t config; - config.server_address = "localhost:" + std::to_string(port_); - - GrpcTestLogCapture log_capture; - config.debug_log_callback = log_capture.client_callback(); - - grpc_client_t client(config); - EXPECT_FALSE(client.connect()); - EXPECT_FALSE(client.get_last_error().empty()); - - EXPECT_TRUE(log_capture.client_log_contains("Connection failed")) - << "Expected failure log. Captured logs:\n" - << log_capture.get_client_logs(); -} - -TEST_F(NoServerTests, LogCaptureInfrastructure) -{ - GrpcTestLogCapture log_capture; - - // -- client_log_count -- - log_capture.add_client_log("Test message 1"); - log_capture.add_client_log("Test message 2"); - log_capture.add_client_log("Another test"); - log_capture.add_client_log("Test message 3"); - - EXPECT_EQ(log_capture.client_log_count("Test message"), 3); - EXPECT_EQ(log_capture.client_log_count("Another"), 1); - EXPECT_EQ(log_capture.client_log_count("Not found"), 0); - - // -- mark_test_start isolation with server log file -- - std::string tmp_log = "/tmp/cuopt_test_log_infra_" + std::to_string(getpid()) + ".log"; - { - std::ofstream f(tmp_log); - f << "[Phase1] Before mark\n"; - f.flush(); - } - - log_capture.set_server_log_path(tmp_log); - log_capture.mark_test_start(); - - // Logs written before the mark should be invisible - EXPECT_FALSE(log_capture.server_log_contains("[Phase1]")) - << "Should NOT see logs from before mark_test_start()"; - - // Append new content after the mark - { - std::ofstream f(tmp_log, std::ios::app); - f << "[Phase2] After mark\n"; - f.flush(); - } - - EXPECT_TRUE(log_capture.server_log_contains("[Phase2]")) - << "Should see logs from after mark_test_start()"; - - // get_all_server_logs bypasses the mark - std::string all = log_capture.get_all_server_logs(); - EXPECT_TRUE(all.find("[Phase1]") != std::string::npos); - EXPECT_TRUE(all.find("[Phase2]") != std::string::npos); - - // -- wait_for_server_log (content already present -> immediate return) -- - EXPECT_TRUE(log_capture.wait_for_server_log("[Phase2]", 500)); - EXPECT_FALSE(log_capture.wait_for_server_log("never_appears", 200)); + auto problem = create_simple_mip(); + pdlp_solver_settings_t settings; + settings.time_limit = 5.0; - // Clean up - std::filesystem::remove(tmp_log); + EXPECT_THROW(solve_lp_remote(problem, settings), std::runtime_error); } // ============================================================================= @@ -582,9 +499,10 @@ class DefaultServerTests : public GrpcIntegrationTestBase { { ASSERT_NE(s_server_, nullptr) << "Shared server not running"; port_ = s_port_; + set_remote_host(port_); } - std::string server_log_path() const { return s_server_->log_path(); } + void TearDown() override { clear_remote_env(); } static std::unique_ptr s_server_; static int s_port_; @@ -593,137 +511,34 @@ class DefaultServerTests : public GrpcIntegrationTestBase { std::unique_ptr DefaultServerTests::s_server_; int DefaultServerTests::s_port_ = 0; -// -- Connectivity -- - TEST_F(DefaultServerTests, ServerAcceptsConnections) { ASSERT_TRUE(s_server_->is_running()); - GrpcTestLogCapture log_capture; - grpc_client_config_t config; - config.debug_log_callback = log_capture.client_callback(); - - auto client = create_client(config); - ASSERT_NE(client, nullptr) << "Failed to connect to server"; - EXPECT_TRUE(client->is_connected()); - - EXPECT_TRUE(log_capture.client_log_contains("Connecting to")) - << "Expected connection log. Logs:\n" - << log_capture.get_client_logs(); - EXPECT_TRUE(log_capture.client_log_contains("Connected successfully")) - << "Expected success log. Logs:\n" - << log_capture.get_client_logs(); -} - -// -- Status / Cancel / Delete on nonexistent jobs -- - -TEST_F(DefaultServerTests, CheckStatusNotFound) -{ - auto client = create_client(); - ASSERT_NE(client, nullptr); - - auto status = client->check_status("nonexistent-job-id"); - EXPECT_TRUE(status.success); - EXPECT_EQ(status.status, job_status_t::NOT_FOUND); -} - -TEST_F(DefaultServerTests, CancelNonexistentJob) -{ - auto client = create_client(); - ASSERT_NE(client, nullptr); - auto result = client->cancel_job("nonexistent-job-id"); - EXPECT_EQ(result.job_status, job_status_t::NOT_FOUND); -} - -TEST_F(DefaultServerTests, DeleteNonexistentJob) -{ - auto client = create_client(); - ASSERT_NE(client, nullptr); - bool deleted = client->delete_job("nonexistent-job-id"); - EXPECT_FALSE(deleted); - EXPECT_FALSE(client->get_last_error().empty()); -} - -TEST_F(DefaultServerTests, StreamLogsNotFound) -{ - auto client = create_client(); - ASSERT_NE(client, nullptr); - - bool callback_called = false; - bool result = - client->stream_logs("nonexistent-job-id", 0, [&callback_called](const std::string&, bool) { - callback_called = true; - return true; - }); - - EXPECT_FALSE(callback_called); - EXPECT_FALSE(result); -} - -TEST_F(DefaultServerTests, GetResultNonexistentJob) -{ - auto client = create_client(); - ASSERT_NE(client, nullptr); - auto result = client->get_lp_result("nonexistent-job-12345"); - EXPECT_FALSE(result.success); -} - -// -- LP Solves -- - -TEST_F(DefaultServerTests, SolveLPPolling) -{ - auto client = create_client(); - ASSERT_NE(client, nullptr); - - std::string mps_path = get_test_lp_path("afiro_original.mps"); - auto problem = load_problem_from_file(mps_path); - pdlp_solver_settings_t settings; + // A successful solve proves the server accepts and processes connections. + auto problem = create_simple_mip(); + mip_solver_settings_t settings; settings.time_limit = 30.0; - auto submit_result = client->submit_lp(problem, settings); - ASSERT_TRUE(submit_result.success) << submit_result.error_message; - EXPECT_FALSE(submit_result.job_id.empty()); - - job_status_t final_status = job_status_t::QUEUED; - for (int i = 0; i < 60; ++i) { - auto status = client->check_status(submit_result.job_id); - ASSERT_TRUE(status.success) << status.error_message; - final_status = status.status; - if (final_status == job_status_t::COMPLETED || final_status == job_status_t::FAILED) break; - std::this_thread::sleep_for(std::chrono::milliseconds(500)); - } - - EXPECT_EQ(final_status, job_status_t::COMPLETED) - << "Status: " << job_status_to_string(final_status); - - auto result = client->get_lp_result(submit_result.job_id); - EXPECT_TRUE(result.success) << result.error_message; - ASSERT_NE(result.solution, nullptr); - EXPECT_NEAR(result.solution->get_objective_value(), -464.753, 1.0); + auto solution = solve_mip_remote(problem, settings); + ASSERT_NE(solution, nullptr); + EXPECT_EQ(solution->get_termination_status(), mip_termination_status_t::Optimal); } -TEST_F(DefaultServerTests, SolveLPWaitRPC) +TEST_F(DefaultServerTests, SolveLPBlocking) { - grpc_client_config_t config; - auto client = create_client(config); - ASSERT_NE(client, nullptr); - std::string mps_path = get_test_lp_path("afiro_original.mps"); auto problem = load_problem_from_file(mps_path); pdlp_solver_settings_t settings; settings.time_limit = 30.0; - auto result = client->solve_lp(problem, settings); - EXPECT_TRUE(result.success) << result.error_message; - ASSERT_NE(result.solution, nullptr); - EXPECT_NEAR(result.solution->get_objective_value(), -464.753, 1.0); + auto solution = solve_lp_remote(problem, settings); + ASSERT_NE(solution, nullptr); + EXPECT_NEAR(solution->get_objective_value(), -464.753, 1.0); } TEST_F(DefaultServerTests, SolveInfeasibleLP) { - auto client = create_client(); - ASSERT_NE(client, nullptr); - cpu_optimization_problem_t problem; std::vector var_lb = {1.0}; std::vector var_ub = {0.0}; @@ -741,392 +556,104 @@ TEST_F(DefaultServerTests, SolveInfeasibleLP) pdlp_solver_settings_t settings; settings.time_limit = 10.0; - auto result = client->solve_lp(problem, settings); - ASSERT_TRUE(result.success) << result.error_message; - ASSERT_NE(result.solution, nullptr); - auto status = result.solution->get_termination_status(); - EXPECT_NE(status, pdlp_termination_status_t::Optimal) + auto solution = solve_lp_remote(problem, settings); + ASSERT_NE(solution, nullptr); + EXPECT_NE(solution->get_termination_status(), pdlp_termination_status_t::Optimal) << "Expected non-optimal termination for infeasible problem"; } -// -- MIP Solve -- - TEST_F(DefaultServerTests, SolveMIPBlocking) { - auto client = create_client(); - ASSERT_NE(client, nullptr); auto problem = create_simple_mip(); - mip_solver_settings_t settings; settings.time_limit = 30.0; - auto result = client->solve_mip(problem, settings, false); - EXPECT_TRUE(result.success) << result.error_message; - ASSERT_NE(result.solution, nullptr); - EXPECT_EQ(result.solution->get_termination_status(), mip_termination_status_t::Optimal); - EXPECT_NEAR(result.solution->get_objective_value(), 1.0, 0.01); -} - -// -- Explicit Async LP Flow (submit/poll/get/delete) -- - -TEST_F(DefaultServerTests, ExplicitAsyncLPFlow) -{ - auto client = create_client(); - ASSERT_NE(client, nullptr); - - std::string mps_path = get_test_lp_path("afiro_original.mps"); - auto problem = load_problem_from_file(mps_path); - pdlp_solver_settings_t settings; - settings.time_limit = 30.0; - - auto submit_result = client->submit_lp(problem, settings); - ASSERT_TRUE(submit_result.success) << submit_result.error_message; - ASSERT_FALSE(submit_result.job_id.empty()); - std::string job_id = submit_result.job_id; - - wait_for_job_done(client.get(), job_id, 30); - - auto result = client->get_lp_result(job_id); - EXPECT_TRUE(result.success) << result.error_message; - ASSERT_NE(result.solution, nullptr); - EXPECT_NEAR(result.solution->get_objective_value(), -464.753, 1.0); - - bool deleted = client->delete_job(job_id); - EXPECT_TRUE(deleted); + auto solution = solve_mip_remote(problem, settings); + ASSERT_NE(solution, nullptr); + EXPECT_EQ(solution->get_termination_status(), mip_termination_status_t::Optimal); + EXPECT_NEAR(solution->get_objective_value(), 1.0, 0.01); } -// -- Log Verification -- - -TEST_F(DefaultServerTests, ServerLogsJobProcessing) -{ - GrpcTestLogCapture log_capture; - log_capture.set_server_log_path(server_log_path()); - log_capture.mark_test_start(); - - auto client = create_client(); - ASSERT_NE(client, nullptr); - - auto problem = create_simple_mip(); - mip_solver_settings_t settings; - settings.time_limit = 10.0; - auto result = client->solve_mip(problem, settings, false); - EXPECT_TRUE(result.success) << result.error_message; - - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - EXPECT_TRUE(log_capture.server_log_contains("[Worker")) - << "Expected worker logs. Server log: " << server_log_path(); -} - -TEST_F(DefaultServerTests, ClientDebugLogsSubmission) +TEST_F(DefaultServerTests, MultipleSequentialSolves) { - GrpcTestLogCapture log_capture; - grpc_client_config_t config; - config.debug_log_callback = log_capture.client_callback(); - auto client = create_client(config); - ASSERT_NE(client, nullptr); - std::string mps_path = get_test_lp_path("afiro_original.mps"); auto problem = load_problem_from_file(mps_path); pdlp_solver_settings_t settings; settings.time_limit = 10.0; - auto result = client->solve_lp(problem, settings); - EXPECT_TRUE(result.success) << result.error_message; - - EXPECT_TRUE(log_capture.client_log_contains("submit")) << "Expected submit log. Logs:\n" - << log_capture.get_client_logs(); - EXPECT_TRUE(log_capture.client_log_contains_pattern("job_id=[a-f0-9-]+")) - << "Expected job_id pattern. Logs:\n" - << log_capture.get_client_logs(); -} - -// -- Multiple & Concurrent Solves -- - -TEST_F(DefaultServerTests, MultipleSequentialSolves) -{ - auto client = create_client(); - ASSERT_NE(client, nullptr); - for (int i = 0; i < 3; ++i) { - std::string mps_path = get_test_lp_path("afiro_original.mps"); - auto problem = load_problem_from_file(mps_path); - pdlp_solver_settings_t settings; - settings.time_limit = 10.0; - - auto result = client->solve_lp(problem, settings); - EXPECT_TRUE(result.success) << "Solve #" << i << " failed: " << result.error_message; - ASSERT_NE(result.solution, nullptr); - EXPECT_NEAR(result.solution->get_objective_value(), -464.753, 1.0); + auto solution = solve_lp_remote(problem, settings); + ASSERT_NE(solution, nullptr) << "Solve #" << i << " returned null"; + EXPECT_NEAR(solution->get_objective_value(), -464.753, 1.0) << "Solve #" << i; } } -TEST_F(DefaultServerTests, ConcurrentJobSubmission) +TEST_F(DefaultServerTests, ConcurrentSolves) { - auto client1 = create_client(); - auto client2 = create_client(); - ASSERT_NE(client1, nullptr); - ASSERT_NE(client2, nullptr); - std::string mps_path = get_test_lp_path("afiro_original.mps"); auto problem = load_problem_from_file(mps_path); pdlp_solver_settings_t settings; settings.time_limit = 30.0; - std::vector> jobs; - - auto s1 = client1->submit_lp(problem, settings); - ASSERT_TRUE(s1.success); - jobs.push_back({client1.get(), s1.job_id}); - - auto s2 = client2->submit_lp(problem, settings); - ASSERT_TRUE(s2.success); - jobs.push_back({client2.get(), s2.job_id}); - - auto s3 = client1->submit_lp(problem, settings); - ASSERT_TRUE(s3.success); - jobs.push_back({client1.get(), s3.job_id}); - - std::vector completed(3, false); - int completed_count = 0; - - for (int poll = 0; poll < 120 && completed_count < 3; ++poll) { - for (size_t i = 0; i < jobs.size(); ++i) { - if (completed[i]) continue; - auto status = jobs[i].first->check_status(jobs[i].second); - ASSERT_TRUE(status.success); - if (status.status == job_status_t::COMPLETED) { - completed[i] = true; - completed_count++; - } else if (status.status == job_status_t::FAILED) { - FAIL() << "Job " << i << " failed: " << status.message; + std::atomic success_count{0}; + auto solve_task = [&]() { + try { + auto solution = solve_lp_remote(problem, settings); + if (solution && std::abs(solution->get_objective_value() - (-464.753)) < 1.0) { + success_count++; } + } catch (const std::exception& e) { + std::cerr << "Concurrent solve exception: " << e.what() << "\n"; } - if (completed_count < 3) { std::this_thread::sleep_for(std::chrono::milliseconds(500)); } - } - - ASSERT_EQ(completed_count, 3) << "Not all jobs completed in time"; + }; - for (size_t i = 0; i < jobs.size(); ++i) { - auto result = jobs[i].first->get_lp_result(jobs[i].second); - EXPECT_TRUE(result.success); - ASSERT_NE(result.solution, nullptr); - EXPECT_NEAR(result.solution->get_objective_value(), -464.753, 1.0); - jobs[i].first->delete_job(jobs[i].second); + std::vector threads; + for (int i = 0; i < 3; ++i) { + threads.emplace_back(solve_task); + } + for (auto& t : threads) { + t.join(); } -} - -// -- Unary Path Verification -- - -TEST_F(DefaultServerTests, VerifyUnaryUploadSmallProblem) -{ - GrpcTestLogCapture log_capture; - grpc_client_config_t config; - config.debug_log_callback = log_capture.client_callback(); - auto client = create_client(config); - ASSERT_NE(client, nullptr); - - std::string mps_path = get_test_lp_path("afiro_original.mps"); - auto problem = load_problem_from_file(mps_path); - pdlp_solver_settings_t settings; - settings.time_limit = 10.0; - - auto result = client->solve_lp(problem, settings); - EXPECT_TRUE(result.success) << result.error_message; - - EXPECT_TRUE(log_capture.client_log_contains("Unary submit succeeded")) - << "Logs:\n" - << log_capture.get_client_logs(); - EXPECT_FALSE(log_capture.client_log_contains("Starting streaming upload")) - << "Logs:\n" - << log_capture.get_client_logs(); -} - -TEST_F(DefaultServerTests, VerifyUnaryDownloadSmallResult) -{ - GrpcTestLogCapture log_capture; - grpc_client_config_t config; - config.debug_log_callback = log_capture.client_callback(); - auto client = create_client(config); - ASSERT_NE(client, nullptr); - - std::string mps_path = get_test_lp_path("afiro_original.mps"); - auto problem = load_problem_from_file(mps_path); - pdlp_solver_settings_t settings; - settings.time_limit = 10.0; - - auto result = client->solve_lp(problem, settings); - EXPECT_TRUE(result.success) << result.error_message; - EXPECT_TRUE(log_capture.client_log_contains("Attempting unary GetResult")) - << "Logs:\n" - << log_capture.get_client_logs(); - EXPECT_TRUE(log_capture.client_log_contains("Unary GetResult succeeded")) - << "Logs:\n" - << log_capture.get_client_logs(); + EXPECT_EQ(success_count.load(), 3); } TEST_F(DefaultServerTests, SolveLPReturnsWarmStartData) { - auto client = create_client(); - ASSERT_NE(client, nullptr); - std::string mps_path = get_test_lp_path("afiro_original.mps"); auto problem = load_problem_from_file(mps_path); pdlp_solver_settings_t settings; settings.time_limit = 30.0; - auto result = client->solve_lp(problem, settings); - EXPECT_TRUE(result.success) << result.error_message; - ASSERT_NE(result.solution, nullptr); - - EXPECT_TRUE(result.solution->has_warm_start_data()) - << "LP solution should contain PDLP warm start data"; - - const auto& ws = result.solution->get_cpu_pdlp_warm_start_data(); - - EXPECT_FALSE(ws.current_primal_solution_.empty()) - << "current_primal_solution should be populated"; - EXPECT_FALSE(ws.current_dual_solution_.empty()) << "current_dual_solution should be populated"; - EXPECT_FALSE(ws.initial_primal_average_.empty()) << "initial_primal_average should be populated"; - EXPECT_FALSE(ws.initial_dual_average_.empty()) << "initial_dual_average should be populated"; - EXPECT_FALSE(ws.current_ATY_.empty()) << "current_ATY should be populated"; - EXPECT_FALSE(ws.sum_primal_solutions_.empty()) << "sum_primal_solutions should be populated"; - EXPECT_FALSE(ws.sum_dual_solutions_.empty()) << "sum_dual_solutions should be populated"; - EXPECT_FALSE(ws.last_restart_duality_gap_primal_solution_.empty()) - << "last_restart_duality_gap_primal_solution should be populated"; - EXPECT_FALSE(ws.last_restart_duality_gap_dual_solution_.empty()) - << "last_restart_duality_gap_dual_solution should be populated"; - - EXPECT_GT(ws.initial_primal_weight_, 0.0) << "initial_primal_weight should be positive"; - EXPECT_GT(ws.initial_step_size_, 0.0) << "initial_step_size should be positive"; - EXPECT_GE(ws.total_pdlp_iterations_, 0) << "total_pdlp_iterations should be non-negative"; - EXPECT_GE(ws.total_pdhg_iterations_, 0) << "total_pdhg_iterations should be non-negative"; -} - -// -- MIP Log Callback -- + auto solution = solve_lp_remote(problem, settings); + ASSERT_NE(solution, nullptr); -TEST_F(DefaultServerTests, SolveMIPWithLogCallback) -{ - std::vector received_logs; - std::mutex log_mutex; - - grpc_client_config_t config; - config.timeout_seconds = 30; - config.stream_logs = true; - config.log_callback = [&](const std::string& line) { - std::lock_guard lock(log_mutex); - received_logs.push_back(line); - }; + EXPECT_TRUE(solution->has_warm_start_data()) << "LP solution should contain PDLP warm start data"; - auto client = create_client(config); - ASSERT_NE(client, nullptr); + const auto& ws = solution->get_cpu_pdlp_warm_start_data(); - std::string mps_path = get_test_mip_path("bb_optimality.mps"); - auto problem = load_problem_from_file(mps_path); + EXPECT_FALSE(ws.current_primal_solution_.empty()); + EXPECT_FALSE(ws.current_dual_solution_.empty()); + EXPECT_FALSE(ws.initial_primal_average_.empty()); + EXPECT_FALSE(ws.initial_dual_average_.empty()); + EXPECT_FALSE(ws.current_ATY_.empty()); + EXPECT_FALSE(ws.sum_primal_solutions_.empty()); + EXPECT_FALSE(ws.sum_dual_solutions_.empty()); + EXPECT_FALSE(ws.last_restart_duality_gap_primal_solution_.empty()); + EXPECT_FALSE(ws.last_restart_duality_gap_dual_solution_.empty()); - mip_solver_settings_t settings; - settings.time_limit = 10.0; - settings.log_to_console = true; - - auto result = client->solve_mip(problem, settings, false); - EXPECT_TRUE(result.success) << result.error_message; -} - -// -- Incumbent Callbacks -- - -TEST_F(DefaultServerTests, IncumbentCallbacksMIP) -{ - std::vector incumbent_objectives; - std::mutex incumbent_mutex; - - grpc_client_config_t config; - config.timeout_seconds = 30; - config.incumbent_callback = [&](int64_t, double objective, const std::vector&) { - std::lock_guard lock(incumbent_mutex); - incumbent_objectives.push_back(objective); - return true; - }; - - auto client = create_client(config); - ASSERT_NE(client, nullptr); - - std::string mps_path = get_test_mip_path("neos5-free-bound.mps"); - auto problem = load_problem_from_file(mps_path); - - mip_solver_settings_t settings; - settings.time_limit = 10.0; - - auto result = client->solve_mip(problem, settings, true); - EXPECT_TRUE(result.success) << result.error_message; - - if (incumbent_objectives.size() > 1) { - for (size_t i = 1; i < incumbent_objectives.size(); ++i) { - EXPECT_LE(incumbent_objectives[i], incumbent_objectives[i - 1] + 1e-6); - } - } -} - -TEST_F(DefaultServerTests, IncumbentCallbackCancelsSolve) -{ - int callback_count = 0; - - grpc_client_config_t config; - config.timeout_seconds = 30; - config.incumbent_callback = [&](int64_t, double, const std::vector&) { - return ++callback_count < 2; - }; - - auto client = create_client(config); - ASSERT_NE(client, nullptr); - - std::string mps_path = get_test_mip_path("neos5-free-bound.mps"); - auto problem = load_problem_from_file(mps_path); - - mip_solver_settings_t settings; - settings.time_limit = 30.0; - - auto start = std::chrono::steady_clock::now(); - auto result = client->solve_mip(problem, settings, true); - auto elapsed = - std::chrono::duration_cast(std::chrono::steady_clock::now() - start); - - EXPECT_LT(elapsed.count(), 25) << "Solve should have cancelled early"; -} - -// -- Cancel Running Job -- - -TEST_F(DefaultServerTests, CancelRunningJob) -{ - auto client = create_client(); - ASSERT_NE(client, nullptr); - - std::string mps_path = get_test_mip_path("neos5-free-bound.mps"); - auto problem = load_problem_from_file(mps_path); - - mip_solver_settings_t settings; - settings.time_limit = 120.0; - - auto submit_result = client->submit_mip(problem, settings); - ASSERT_TRUE(submit_result.success); - std::string job_id = submit_result.job_id; - - std::this_thread::sleep_for(std::chrono::seconds(2)); - - auto cancel_result = client->cancel_job(job_id); - EXPECT_TRUE(cancel_result.job_status == job_status_t::CANCELLED || - cancel_result.job_status == job_status_t::COMPLETED || - cancel_result.job_status == job_status_t::PROCESSING || - cancel_result.job_status == job_status_t::FAILED) - << "Unexpected job_status=" << static_cast(cancel_result.job_status) - << " message=" << cancel_result.message; - - // Wait for worker to free up before next test - wait_for_job_done(client.get(), job_id, 15); - client->delete_job(job_id); + EXPECT_GT(ws.initial_primal_weight_, 0.0); + EXPECT_GT(ws.initial_step_size_, 0.0); + EXPECT_GE(ws.total_pdlp_iterations_, 0); + EXPECT_GE(ws.total_pdhg_iterations_, 0); } // ============================================================================= -// Chunked Upload Tests (--max-message-mb 256) +// Chunked Upload Tests +// +// Uses CUOPT_MAX_MESSAGE_BYTES=4096 so any problem larger than ~3 KiB is +// transported via the chunked array protocol. Verifies that the chunked +// path produces correct results and that QCQP problems round-trip cleanly. // ============================================================================= class ChunkedUploadTests : public GrpcIntegrationTestBase { @@ -1149,21 +676,12 @@ class ChunkedUploadTests : public GrpcIntegrationTestBase { { ASSERT_NE(s_server_, nullptr); port_ = s_port_; + set_remote_host(port_); + // Small message limit forces chunked upload for problems larger than ~3 KiB. + setenv("CUOPT_MAX_MESSAGE_BYTES", "4096", 1); } - void TearDown() override - { - if (HasFailure() && s_server_) { - std::string log_file = s_server_->log_path(); - if (!log_file.empty()) { - std::ifstream f(log_file); - if (f) { - std::cerr << "\n=== Server log (" << log_file << ") ===\n" - << f.rdbuf() << "\n=== End server log ===\n"; - } - } - } - } + void TearDown() override { clear_remote_env(); } static std::unique_ptr s_server_; static int s_port_; @@ -1174,131 +692,62 @@ int ChunkedUploadTests::s_port_ = 0; TEST_F(ChunkedUploadTests, ChunkedUploadLP) { - grpc_client_config_t config; - config.timeout_seconds = 60; - config.chunk_size_bytes = 8 * 1024; - config.chunked_array_threshold_bytes = 0; // Force chunked upload - - auto client = create_client(config); - ASSERT_NE(client, nullptr); - std::string mps_path = get_test_lp_path("afiro_original.mps"); auto problem = load_problem_from_file(mps_path); pdlp_solver_settings_t settings; settings.time_limit = 30.0; - auto result = client->solve_lp(problem, settings); - EXPECT_TRUE(result.success) << result.error_message; - ASSERT_NE(result.solution, nullptr); - EXPECT_NEAR(result.solution->get_objective_value(), -464.753, 1.0); + auto solution = solve_lp_remote(problem, settings); + ASSERT_NE(solution, nullptr); + EXPECT_NEAR(solution->get_objective_value(), -464.753, 1.0); } TEST_F(ChunkedUploadTests, ChunkedUploadMIP) { - grpc_client_config_t config; - config.timeout_seconds = 60; - config.chunk_size_bytes = 4 * 1024; - config.chunked_array_threshold_bytes = 0; // Force chunked upload - - auto client = create_client(config); - ASSERT_NE(client, nullptr); - std::string mps_path = get_test_mip_path("sudoku.mps"); auto problem = load_problem_from_file(mps_path); - mip_solver_settings_t settings; - settings.time_limit = 10.0; + settings.time_limit = 30.0; - auto result = client->solve_mip(problem, settings, false); - EXPECT_TRUE(result.success) << result.error_message; + auto solution = solve_mip_remote(problem, settings); + ASSERT_NE(solution, nullptr); + EXPECT_EQ(solution->get_termination_status(), mip_termination_status_t::Optimal); } -TEST_F(ChunkedUploadTests, ConcurrentChunkedUploads) +TEST_F(ChunkedUploadTests, ConcurrentSolves) { - const int num_clients = 3; - std::vector> clients; - - for (int i = 0; i < num_clients; ++i) { - grpc_client_config_t config; - config.timeout_seconds = 60; - config.chunk_size_bytes = 4 * 1024; - config.chunked_array_threshold_bytes = 0; - auto client = create_client(config); - ASSERT_NE(client, nullptr); - clients.push_back(std::move(client)); - } - std::string mps_path = get_test_lp_path("afiro_original.mps"); auto problem = load_problem_from_file(mps_path); pdlp_solver_settings_t settings; settings.time_limit = 30.0; std::atomic success_count{0}; - - auto solve_task = [&](int idx) -> bool { - auto result = clients[idx]->solve_lp(problem, settings); - if (result.success && result.solution && - std::abs(result.solution->get_objective_value() - (-464.753)) < 1.0) { - success_count++; - return true; + auto solve_task = [&]() { + try { + auto solution = solve_lp_remote(problem, settings); + if (solution && std::abs(solution->get_objective_value() - (-464.753)) < 1.0) { + success_count++; + } + } catch (const std::exception& e) { + std::cerr << "ConcurrentSolves exception: " << e.what() << "\n"; } - return false; }; - std::vector> futures; - for (int i = 0; i < num_clients; ++i) { - futures.push_back(std::async(std::launch::async, solve_task, i)); + std::vector threads; + for (int i = 0; i < 3; ++i) { + threads.emplace_back(solve_task); } - - for (int i = 0; i < num_clients; ++i) { - EXPECT_TRUE(futures[i].get()) << "Client " << i << " failed"; + for (auto& t : threads) { + t.join(); } - EXPECT_EQ(success_count.load(), num_clients); + EXPECT_EQ(success_count.load(), 3); } -// ============================================================================= -// QCQP transport + solve integration tests. -// -// These tests submit problems with quadratic constraints end-to-end through -// gRPC to verify two layers: -// -// 1. Wire transport — the container-chunk path (proto, server validation, -// pipe wire format, worker reassembly). Both QC_Test_1 (rhs != 0, -// SOC-incompatible) and QC_Test_2 (rhs = 0, SOC-friendly) exercise this -// layer; SOC compatibility is irrelevant for transport. -// -// 2. End-to-end SOCP correctness — solve_lp dispatches QCQP problems to -// solve_qcqp, which converts each QC to a second-order cone and runs -// barrier. The QC_Test_2 test asserts the returned solution matches -// the closed-form optimum, catching solver and SOC-conversion -// regressions in addition to transport. -// -// Note on error propagation: solve_lp / solve_qcqp catch cuopt::logic_error -// internally and stash it in optimization_problem_solution_t::error_status_ -// instead of throwing (long-standing solver-API contract; see solve.cu). -// run_lp_solve in grpc_worker.cpp inspects error_status_ after solve_lp -// returns and forwards non-Success errors as sr.error_message, so the -// QC_Test_1 test below can rely on result.success being false with a -// validation message rather than getting a zero-filled "successful" -// response. -// ============================================================================= - -// Submit QC_Test_1 on the unary path. rhs != 0 on its QC rows so SOC -// conversion rejects the problem, but the *transport* layer (proto encoding, -// unary SubmitJob path) must still round-trip the wire format cleanly and -// the worker must surface the SOC validator's ValidationError back to the -// client. -TEST_F(ChunkedUploadTests, QuadraticConstraintsUnaryNonZeroRhs) +// Verify that the QCQP wire format survives the chunked transport path and +// that the general convex quadratic solver handles nonzero-RHS QC rows. +TEST_F(ChunkedUploadTests, QuadraticConstraintsNonZeroRhs) { - grpc_client_config_t config; - config.timeout_seconds = 60; - // Generous threshold ensures the small QC problem stays on the unary path. - config.chunked_array_threshold_bytes = 100 * 1024 * 1024; - - auto client = create_client(config); - ASSERT_NE(client, nullptr); - std::string mps_path = get_test_data_path("qcqp", "QC_Test_1.mps"); auto problem = load_problem_from_file(mps_path); ASSERT_TRUE(problem.has_quadratic_constraints()); @@ -1307,84 +756,16 @@ TEST_F(ChunkedUploadTests, QuadraticConstraintsUnaryNonZeroRhs) pdlp_solver_settings_t settings; settings.time_limit = 10.0; - auto result = client->solve_lp(problem, settings); - // QC_Test_1 has rhs = 5 / rhs = 10. The general convex quadratic path - // handles nonzero RHS, so the problem should be accepted and solved. - // This proves the QCQP wire format made it intact through the unary submit path. - EXPECT_TRUE(result.success); + // QC_Test_1 has rhs != 0; handled by the general convex quadratic path. + auto solution = solve_lp_remote(problem, settings); + EXPECT_TRUE(solution != nullptr); } -// Force the chunked upload path with both a zero-byte threshold (every array -// goes via SendArrayChunk) and a deliberately tiny chunk_size_bytes so each -// per-row QC array is split across multiple ArrayChunks. This exercises: -// * Client: chunk_container_typed_array emitting cfn/ci-stamped chunks. -// * Server: SendArrayChunk routing container chunks into -// container_field_meta and validating against -// array_field_element_size(cfn, fid). -// * Pipe: the new container_arrays wire section with multi-chunk -// stitching inside write_chunked_request_to_pipe. -// * Worker: read_chunked_request_from_pipe + map_chunked_arrays_to_problem -// reconstructing QC entries from header scalars + container bytes. -// -// QC_Test_1 is again SOC-incompatible (rhs != 0); we assert the same -// validator-rejection error message as the unary case so a transport bug -// that drops or duplicates QC array bytes would manifest as a *different* -// failure mode (typically a malformed-problem error or a successful solve -// of a tampered problem) rather than the expected rhs=0 rejection. -TEST_F(ChunkedUploadTests, QuadraticConstraintsChunkedNonZeroRhs) -{ - grpc_client_config_t config; - config.timeout_seconds = 60; - // Tiny chunk size forces multiple chunks per container array even for - // QC_Test_1's small linear/quadratic vectors (8 bytes / double, 4 / int). - config.chunk_size_bytes = 8; - config.chunked_array_threshold_bytes = 0; - - auto client = create_client(config); - ASSERT_NE(client, nullptr); - - std::string mps_path = get_test_data_path("qcqp", "QC_Test_1.mps"); - auto problem = load_problem_from_file(mps_path); - ASSERT_TRUE(problem.has_quadratic_constraints()); - - pdlp_solver_settings_t settings; - settings.time_limit = 10.0; - - auto result = client->solve_lp(problem, settings); - // QC_Test_1 has nonzero RHS, now handled by the general convex quadratic path. - // This proves the chunked wire format correctly transmits QC data. - EXPECT_TRUE(result.success); -} - -// End-to-end SOCP correctness via gRPC: QC_Test_2 is a small convex QCQP -// authored in LP format (rather than MPS) so the file's algebraic content -// is human-readable at review time — a reviewer can see `[ -2 x*y + z^2 ] -// <= 0` directly and check it against the comment, instead of decoding -// QCMATRIX triples. It exercises the same wire-format aspects as -// QC_Test_1 (multiple QCs, off-diagonal cross-terms, linear-in-QC-row -// terms, normal LP constraint alongside QCs) while being SOC-friendly: -// rhs = 0 on every QC, each QC's structure matches one of the SOC -// validator's accepted shapes (rotated SOC for QC0, affine SOC for QC1). -// -// The problem has a closed-form optimum derived in the file's header -// comment: x = y = 1/sqrt(2), z = 1, objective = -(1 + sqrt(2)). Asserting -// these values via gRPC verifies that QC encoding, chunked transport, -// SOC conversion, barrier solve, and result decoding are all wired together -// correctly. +// End-to-end SOCP correctness via gRPC. +// QC_Test_2 is SOC-compatible (rhs = 0 on every QC). +// Closed-form optimum: x = y = 1/sqrt(2), z = 1, obj = -(1 + sqrt(2)). TEST_F(ChunkedUploadTests, QuadraticConstraintsEndToEndSocp) { - grpc_client_config_t config; - config.timeout_seconds = 60; - // Force the chunked path so this test also covers chunked transport of a - // SOC-compatible problem (the rejection tests above only prove the chunked - // path delivers bytes intact for an *infeasible-for-SOC* problem; here we - // additionally prove a chunked SOC-friendly problem solves correctly). - config.chunk_size_bytes = 8; - config.chunked_array_threshold_bytes = 0; - - auto client = create_client(config); - ASSERT_NE(client, nullptr); - std::string lp_path = get_test_data_path("qcqp", "QC_Test_2.lp"); auto problem = load_problem_from_file(lp_path); ASSERT_TRUE(problem.has_quadratic_constraints()); @@ -1393,247 +774,25 @@ TEST_F(ChunkedUploadTests, QuadraticConstraintsEndToEndSocp) pdlp_solver_settings_t settings; settings.time_limit = 30.0; - auto result = client->solve_lp(problem, settings); - ASSERT_TRUE(result.success) << result.error_message; - ASSERT_NE(result.solution, nullptr); + auto solution = solve_lp_remote(problem, settings); + ASSERT_NE(solution, nullptr); - EXPECT_EQ(result.solution->get_termination_status(), pdlp_termination_status_t::Optimal); + EXPECT_EQ(solution->get_termination_status(), pdlp_termination_status_t::Optimal); - const double sqrt2 = std::sqrt(2.0); - const double opt_obj = -(1.0 + sqrt2); - const double opt_x_y = 1.0 / sqrt2; - const double opt_z = 1.0; - // Barrier converges to ~1e-6; allow 1e-3 to absorb tolerance settings and - // future numeric drift without masking real regressions. constexpr double kTol = 1e-3; - EXPECT_NEAR(result.solution->get_objective_value(), opt_obj, kTol); + const double sqrt2 = std::sqrt(2.0); + const double opt_obj = -(1.0 + sqrt2); + const double opt_x_y = 1.0 / sqrt2; + const double opt_z = 1.0; + EXPECT_NEAR(solution->get_objective_value(), opt_obj, kTol); - const auto primal = result.solution->get_primal_solution_host(); + const auto primal = solution->get_primal_solution_host(); ASSERT_GE(primal.size(), 3u); EXPECT_NEAR(primal[0], opt_x_y, kTol); EXPECT_NEAR(primal[1], opt_x_y, kTol); EXPECT_NEAR(primal[2], opt_z, kTol); } -TEST_F(ChunkedUploadTests, UnaryFallbackSmallProblem) -{ - grpc_client_config_t config; - config.timeout_seconds = 60; - config.chunked_array_threshold_bytes = 100 * 1024 * 1024; // 100 MiB, well above afiro size - - auto client = create_client(config); - ASSERT_NE(client, nullptr); - - std::string mps_path = get_test_lp_path("afiro_original.mps"); - auto problem = load_problem_from_file(mps_path); - pdlp_solver_settings_t settings; - settings.time_limit = 30.0; - - auto result = client->solve_lp(problem, settings); - EXPECT_TRUE(result.success) << result.error_message; - ASSERT_NE(result.solution, nullptr); - EXPECT_NEAR(result.solution->get_objective_value(), -464.753, 1.0); -} - -// ============================================================================= -// Path Selection Tests (unary vs chunked IPC and result retrieval) -// -// Uses --max-message-bytes to set a very low logical threshold so that even -// small test problems exercise both unary and chunked code paths. -// Uses --verbose so the server emits IPC path tags we can verify in logs. -// ============================================================================= - -class PathSelectionTests : public GrpcIntegrationTestBase { - protected: - static void SetUpTestSuite() - { - s_port_ = get_test_port(); - s_server_ = std::make_unique(); - // Small threshold (clamped to 4 KiB) forces chunked result downloads for - // anything larger than ~4 KB, exercising the chunked download path. - ASSERT_TRUE(s_server_->start(s_port_, {"--max-message-bytes", "4096", "--verbose"})) - << "Failed to start path-selection server"; - } - - static void TearDownTestSuite() - { - if (s_server_) EXPECT_TRUE(s_server_->stop()); - s_server_.reset(); - } - - void SetUp() override - { - ASSERT_NE(s_server_, nullptr); - port_ = s_port_; - } - - std::string server_log_path() const { return s_server_->log_path(); } - - static std::unique_ptr s_server_; - static int s_port_; -}; - -std::unique_ptr PathSelectionTests::s_server_; -int PathSelectionTests::s_port_ = 0; - -// Unary upload for a small LP (afiro). The result is small enough that -// the server returns it via unary GetResult. We verify the upload and -// result paths in the server logs but don't assert the download method -// since the result size may or may not exceed the 4 KiB threshold. -TEST_F(PathSelectionTests, UnaryUploadLPWithPathLogging) -{ - GrpcTestLogCapture log_capture; - log_capture.set_server_log_path(server_log_path()); - log_capture.mark_test_start(); - - grpc_client_config_t config; - config.timeout_seconds = 60; - config.chunked_array_threshold_bytes = 100 * 1024 * 1024; // high threshold => unary upload - config.debug_log_callback = log_capture.client_callback(); - - auto client = create_client(config); - ASSERT_NE(client, nullptr); - - std::string mps_path = get_test_lp_path("afiro_original.mps"); - auto problem = load_problem_from_file(mps_path); - pdlp_solver_settings_t settings; - settings.time_limit = 30.0; - - auto result = client->solve_lp(problem, settings); - EXPECT_TRUE(result.success) << result.error_message; - ASSERT_NE(result.solution, nullptr); - EXPECT_NEAR(result.solution->get_objective_value(), -464.753, 1.0); - - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - - // Worker should have received via the UNARY path - EXPECT_TRUE(log_capture.wait_for_server_log("[Worker] IPC path: UNARY LP", 5000)) - << "Expected UNARY LP path in server log.\nServer log:\n" - << log_capture.get_server_logs(); - - // Worker should have serialized the result - EXPECT_TRUE(log_capture.server_log_contains("[Worker] Result path: LP solution")) - << "Expected LP result path in server log.\nServer log:\n" - << log_capture.get_server_logs(); -} - -// Chunked upload, verify server receives via CHUNKED path -TEST_F(PathSelectionTests, ChunkedUploadLPWithPathLogging) -{ - GrpcTestLogCapture log_capture; - log_capture.set_server_log_path(server_log_path()); - log_capture.mark_test_start(); - - grpc_client_config_t config; - config.timeout_seconds = 60; - config.chunk_size_bytes = 4 * 1024; - config.chunked_array_threshold_bytes = 0; // force chunked upload - config.debug_log_callback = log_capture.client_callback(); - - auto client = create_client(config); - ASSERT_NE(client, nullptr); - - std::string mps_path = get_test_lp_path("afiro_original.mps"); - auto problem = load_problem_from_file(mps_path); - pdlp_solver_settings_t settings; - settings.time_limit = 30.0; - - auto result = client->solve_lp(problem, settings); - EXPECT_TRUE(result.success) << result.error_message; - ASSERT_NE(result.solution, nullptr); - EXPECT_NEAR(result.solution->get_objective_value(), -464.753, 1.0); - - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - - // Worker should have received via the CHUNKED path - EXPECT_TRUE(log_capture.wait_for_server_log("[Worker] IPC path: CHUNKED", 5000)) - << "Expected CHUNKED path in server log.\nServer log:\n" - << log_capture.get_server_logs(); - - // Server main process should have logged FinishChunkedUpload - EXPECT_TRUE(log_capture.server_log_contains("FinishChunkedUpload: CHUNKED path")) - << "Expected FinishChunkedUpload log.\nServer log:\n" - << log_capture.get_server_logs(); -} - -// Chunked upload + chunked result download for MIP. -// sudoku.mps produces ~5.8 KB result which exceeds the 4 KB threshold, -// so the client should use chunked download. -TEST_F(PathSelectionTests, ChunkedUploadAndChunkedDownloadMIP) -{ - GrpcTestLogCapture log_capture; - log_capture.set_server_log_path(server_log_path()); - log_capture.mark_test_start(); - - grpc_client_config_t config; - config.timeout_seconds = 60; - config.chunk_size_bytes = 4 * 1024; - config.chunked_array_threshold_bytes = 0; // force chunked upload - config.debug_log_callback = log_capture.client_callback(); - - auto client = create_client(config); - ASSERT_NE(client, nullptr); - - std::string mps_path = get_test_mip_path("sudoku.mps"); - auto problem = load_problem_from_file(mps_path); - mip_solver_settings_t settings; - settings.time_limit = 30.0; - - auto result = client->solve_mip(problem, settings, false); - EXPECT_TRUE(result.success) << result.error_message; - - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - - // Upload should have gone through the CHUNKED path - EXPECT_TRUE(log_capture.wait_for_server_log("[Worker] IPC path: CHUNKED", 5000)) - << "Expected CHUNKED upload path in server log.\nServer log:\n" - << log_capture.get_server_logs(); - - // Client should have used chunked download (result > 4096 bytes) - EXPECT_TRUE(log_capture.client_log_contains("chunked download") || - log_capture.client_log_contains("ChunkedDownload")) - << "Expected chunked download in client log.\nClient log:\n" - << log_capture.get_client_logs(); - - // Server should log CHUNKED response - EXPECT_TRUE(log_capture.wait_for_server_log("StartChunkedDownload: CHUNKED response", 5000)) - << "Expected chunked download path in server log.\nServer log:\n" - << log_capture.get_server_logs(); -} - -// MIP path: unary upload, verify UNARY MIP tag -TEST_F(PathSelectionTests, UnaryUploadMIPWithPathLogging) -{ - GrpcTestLogCapture log_capture; - log_capture.set_server_log_path(server_log_path()); - log_capture.mark_test_start(); - - grpc_client_config_t config; - config.timeout_seconds = 60; - config.debug_log_callback = log_capture.client_callback(); - - auto client = create_client(config); - ASSERT_NE(client, nullptr); - - std::string mps_path = get_test_mip_path("bb_optimality.mps"); - auto problem = load_problem_from_file(mps_path); - mip_solver_settings_t settings; - settings.time_limit = 10.0; - - auto result = client->solve_mip(problem, settings, false); - EXPECT_TRUE(result.success) << result.error_message; - - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - - EXPECT_TRUE(log_capture.wait_for_server_log("[Worker] IPC path: UNARY MIP", 5000)) - << "Expected UNARY MIP path in server log.\nServer log:\n" - << log_capture.get_server_logs(); - - EXPECT_TRUE(log_capture.server_log_contains("[Worker] Result path: MIP solution")) - << "Expected MIP result path in server log.\nServer log:\n" - << log_capture.get_server_logs(); -} - // ============================================================================= // Error Recovery Tests (per-test server lifecycle) // ============================================================================= @@ -1641,7 +800,11 @@ TEST_F(PathSelectionTests, UnaryUploadMIPWithPathLogging) class ErrorRecoveryTests : public GrpcIntegrationTestBase { protected: void SetUp() override { port_ = get_test_port(); } - void TearDown() override { EXPECT_TRUE(server_.stop()); } + void TearDown() override + { + clear_remote_env(); + EXPECT_TRUE(server_.stop()); + } bool start_server(const std::vector& extra_args = {}) { @@ -1651,115 +814,28 @@ class ErrorRecoveryTests : public GrpcIntegrationTestBase { ServerProcess server_; }; -TEST_F(ErrorRecoveryTests, ClientReconnectsAfterServerRestart) -{ - ASSERT_TRUE(start_server()); - auto client = create_client(); - ASSERT_NE(client, nullptr); - - auto status_before = client->check_status("test-job"); - EXPECT_TRUE(status_before.success); - - ASSERT_TRUE(server_.stop()); - EXPECT_FALSE(server_.is_running()); - - auto status_down = client->check_status("test-job"); - EXPECT_FALSE(status_down.success); - - ASSERT_TRUE(start_server()); - - auto status_after = client->check_status("test-job"); - EXPECT_TRUE(status_after.success) << "Should auto-reconnect: " << status_after.error_message; -} - -TEST_F(ErrorRecoveryTests, ClientHandlesServerCrashDuringSolve) -{ - ASSERT_TRUE(start_server()); - auto client = create_client(); - ASSERT_NE(client, nullptr); - - std::string mps_path = get_test_mip_path("neos5-free-bound.mps"); - auto problem = load_problem_from_file(mps_path); - - mip_solver_settings_t settings; - settings.time_limit = 120.0; - - auto submit_result = client->submit_mip(problem, settings); - ASSERT_TRUE(submit_result.success); - - std::this_thread::sleep_for(std::chrono::milliseconds(500)); - EXPECT_TRUE(server_.stop()); - - auto status_result = client->check_status(submit_result.job_id); - EXPECT_FALSE(status_result.success); - EXPECT_FALSE(status_result.error_message.empty()); -} - -TEST_F(ErrorRecoveryTests, ClientTimeoutConfiguration) -{ - ASSERT_TRUE(start_server()); - - grpc_client_config_t config; - config.timeout_seconds = 1; - config.poll_interval_ms = 100; - - auto client = create_client(config); - ASSERT_NE(client, nullptr); - - std::string mps_path = get_test_mip_path("neos5-free-bound.mps"); - auto problem = load_problem_from_file(mps_path); - - mip_solver_settings_t settings; - settings.time_limit = 60.0; - - auto submit_result = client->submit_mip(problem, settings); - ASSERT_TRUE(submit_result.success); - - auto start = std::chrono::steady_clock::now(); - bool completed = false; - while (!completed) { - auto elapsed = - std::chrono::duration_cast(std::chrono::steady_clock::now() - start); - if (elapsed.count() >= config.timeout_seconds) break; - auto status = client->check_status(submit_result.job_id); - if (status.status == job_status_t::COMPLETED || status.status == job_status_t::FAILED) { - completed = true; - } - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - } - - EXPECT_FALSE(completed) << "Complex MIP should not complete in 1 second"; - client->cancel_job(submit_result.job_id); -} - -TEST_F(ErrorRecoveryTests, ChunkedUploadAfterServerRestart) +TEST_F(ErrorRecoveryTests, SolveMIPAfterServerRestart) { ASSERT_TRUE(start_server({"--max-message-mb", "256"})); - - grpc_client_config_t config; - config.timeout_seconds = 30; - config.chunk_size_bytes = 4 * 1024; - config.chunked_array_threshold_bytes = 0; - - auto client = create_client(config); - ASSERT_NE(client, nullptr); + set_remote_host(port_); std::string mps_path = get_test_mip_path("sudoku.mps"); auto problem = load_problem_from_file(mps_path); mip_solver_settings_t settings; settings.time_limit = 10.0; - auto result1 = client->solve_mip(problem, settings, false); - EXPECT_TRUE(result1.success) << result1.error_message; + auto solution1 = solve_mip_remote(problem, settings); + ASSERT_NE(solution1, nullptr) << "First solve failed"; + EXPECT_EQ(solution1->get_termination_status(), mip_termination_status_t::Optimal) + << "First solve did not reach optimal"; ASSERT_TRUE(server_.stop()); ASSERT_TRUE(start_server({"--max-message-mb", "256"})); - auto client2 = create_client(config); - ASSERT_NE(client2, nullptr); - - auto result2 = client2->solve_mip(problem, settings, false); - EXPECT_TRUE(result2.success) << result2.error_message; + auto solution2 = solve_mip_remote(problem, settings); + ASSERT_NE(solution2, nullptr) << "Second solve after restart failed"; + EXPECT_EQ(solution2->get_termination_status(), mip_termination_status_t::Optimal) + << "Second solve after restart did not reach optimal"; } // ============================================================================= @@ -1812,20 +888,12 @@ class TlsServerTests : public GrpcIntegrationTestBase { if (!s_certs_available_) { GTEST_SKIP() << "TLS certificates not available"; } ASSERT_NE(s_server_, nullptr) << "TLS server not running"; port_ = s_port_; + set_remote_host(port_); + setenv("CUOPT_TLS_ENABLED", "1", 1); + setenv("CUOPT_TLS_ROOT_CERT", (g_tls_certs_dir + "/ca.crt").c_str(), 1); } - std::unique_ptr create_tls_client() - { - grpc_client_config_t config; - config.server_address = "localhost:" + std::to_string(port_); - config.timeout_seconds = 30; - config.enable_tls = true; - config.tls_root_certs = read_file_contents(g_tls_certs_dir + "/ca.crt"); - - auto client = std::make_unique(config); - if (!client->connect()) return nullptr; - return client; - } + void TearDown() override { clear_remote_env(); } static std::unique_ptr s_server_; static int s_port_; @@ -1836,27 +904,16 @@ std::unique_ptr TlsServerTests::s_server_; int TlsServerTests::s_port_ = 0; bool TlsServerTests::s_certs_available_ = false; -TEST_F(TlsServerTests, BasicConnection) -{ - auto client = create_tls_client(); - ASSERT_NE(client, nullptr) << "Failed to connect with TLS"; - EXPECT_TRUE(client->is_connected()); -} - TEST_F(TlsServerTests, SolveLP) { - auto client = create_tls_client(); - ASSERT_NE(client, nullptr); - std::string mps_path = get_test_lp_path("afiro_original.mps"); auto problem = load_problem_from_file(mps_path); pdlp_solver_settings_t settings; settings.time_limit = 10.0; - auto result = client->solve_lp(problem, settings); - EXPECT_TRUE(result.success) << result.error_message; - ASSERT_NE(result.solution, nullptr); - EXPECT_NEAR(result.solution->get_objective_value(), -464.753, 1.0); + auto solution = solve_lp_remote(problem, settings); + ASSERT_NE(solution, nullptr); + EXPECT_NEAR(solution->get_objective_value(), -464.753, 1.0); } // ============================================================================= @@ -1913,25 +970,14 @@ class MtlsServerTests : public GrpcIntegrationTestBase { if (!s_certs_available_) { GTEST_SKIP() << "mTLS certificates not available"; } ASSERT_NE(s_server_, nullptr) << "mTLS server not running"; port_ = s_port_; + set_remote_host(port_); + setenv("CUOPT_TLS_ENABLED", "1", 1); + setenv("CUOPT_TLS_ROOT_CERT", (g_tls_certs_dir + "/ca.crt").c_str(), 1); + setenv("CUOPT_TLS_CLIENT_CERT", (g_tls_certs_dir + "/client.crt").c_str(), 1); + setenv("CUOPT_TLS_CLIENT_KEY", (g_tls_certs_dir + "/client.key").c_str(), 1); } - std::unique_ptr create_mtls_client(bool with_client_cert = true) - { - grpc_client_config_t config; - config.server_address = "localhost:" + std::to_string(port_); - config.timeout_seconds = 30; - config.enable_tls = true; - config.tls_root_certs = read_file_contents(g_tls_certs_dir + "/ca.crt"); - - if (with_client_cert) { - config.tls_client_cert = read_file_contents(g_tls_certs_dir + "/client.crt"); - config.tls_client_key = read_file_contents(g_tls_certs_dir + "/client.key"); - } - - auto client = std::make_unique(config); - if (!client->connect()) return nullptr; - return client; - } + void TearDown() override { clear_remote_env(); } static std::unique_ptr s_server_; static int s_port_; @@ -1942,25 +988,36 @@ std::unique_ptr MtlsServerTests::s_server_; int MtlsServerTests::s_port_ = 0; bool MtlsServerTests::s_certs_available_ = false; -TEST_F(MtlsServerTests, ConnectionWithClientCert) +TEST_F(MtlsServerTests, SolveLP) { - auto client = create_mtls_client(true); - ASSERT_NE(client, nullptr) << "Failed to connect with mTLS"; - EXPECT_TRUE(client->is_connected()); + std::string mps_path = get_test_lp_path("afiro_original.mps"); + auto problem = load_problem_from_file(mps_path); + pdlp_solver_settings_t settings; + settings.time_limit = 10.0; + + auto solution = solve_lp_remote(problem, settings); + ASSERT_NE(solution, nullptr); + EXPECT_NEAR(solution->get_objective_value(), -464.753, 1.0); } TEST_F(MtlsServerTests, RejectsClientWithoutCert) { - auto client = create_mtls_client(false); - EXPECT_EQ(client, nullptr) << "Server should reject client without certificate"; + // Unset client cert/key — server requires them. + unsetenv("CUOPT_TLS_CLIENT_CERT"); + unsetenv("CUOPT_TLS_CLIENT_KEY"); + + auto problem = create_simple_mip(); + pdlp_solver_settings_t settings; + settings.time_limit = 5.0; + + EXPECT_THROW(solve_lp_remote(problem, settings), std::runtime_error); } // ============================================================================= // Chunk Validation Tests // // Uses a raw gRPC stub to send malformed chunk requests and verify the server -// rejects them with appropriate error codes. Exercises items 1-8 from the -// chunked transfer hardening work. +// rejects them with appropriate error codes. // ============================================================================= class ChunkValidationTests : public GrpcIntegrationTestBase { @@ -2031,7 +1088,7 @@ int ChunkValidationTests::s_port_ = 0; TEST_F(ChunkValidationTests, RejectsNegativeElementOffset) { auto uid = start_upload(); - std::string data(8, '\0'); // 1 double + std::string data(8, '\0'); auto status = send_chunk(uid, cuopt::remote::FIELD_C, -1, 10, data); EXPECT_FALSE(status.ok()); EXPECT_EQ(status.error_code(), grpc::StatusCode::INVALID_ARGUMENT); @@ -2071,12 +1128,10 @@ TEST_F(ChunkValidationTests, RejectsInvalidFieldId) TEST_F(ChunkValidationTests, RejectsUnalignedChunkData) { auto uid = start_upload(); - // First chunk to allocate the array (doubles, elem_size=8) - std::string good_data(80, '\0'); // 10 doubles + std::string good_data(80, '\0'); auto s1 = send_chunk(uid, cuopt::remote::FIELD_C, 0, 10, good_data); EXPECT_TRUE(s1.ok()) << s1.error_message(); - // Send a misaligned chunk (7 bytes, not a multiple of 8) std::string bad_data(7, '\0'); auto s2 = send_chunk(uid, cuopt::remote::FIELD_C, 0, 10, bad_data); EXPECT_FALSE(s2.ok()); @@ -2087,12 +1142,10 @@ TEST_F(ChunkValidationTests, RejectsUnalignedChunkData) TEST_F(ChunkValidationTests, RejectsOffsetBeyondArraySize) { auto uid = start_upload(); - // Allocate array of 10 doubles std::string data(80, '\0'); auto s1 = send_chunk(uid, cuopt::remote::FIELD_C, 0, 10, data); EXPECT_TRUE(s1.ok()) << s1.error_message(); - // Offset 100 is way past the 10-element array std::string small_data(8, '\0'); auto s2 = send_chunk(uid, cuopt::remote::FIELD_C, 100, 10, small_data); EXPECT_FALSE(s2.ok()); @@ -2102,13 +1155,11 @@ TEST_F(ChunkValidationTests, RejectsOffsetBeyondArraySize) TEST_F(ChunkValidationTests, RejectsChunkOverflow) { auto uid = start_upload(); - // Allocate array of 4 doubles (32 bytes) std::string init_data(32, '\0'); auto s1 = send_chunk(uid, cuopt::remote::FIELD_C, 0, 4, init_data); EXPECT_TRUE(s1.ok()) << s1.error_message(); - // Offset 3 + 2 doubles = writes past end - std::string over_data(16, '\0'); // 2 doubles + std::string over_data(16, '\0'); auto s2 = send_chunk(uid, cuopt::remote::FIELD_C, 3, 4, over_data); EXPECT_FALSE(s2.ok()); EXPECT_EQ(s2.error_code(), grpc::StatusCode::INVALID_ARGUMENT); @@ -2122,11 +1173,6 @@ TEST_F(ChunkValidationTests, RejectsUnknownUploadId) EXPECT_EQ(status.error_code(), grpc::StatusCode::NOT_FOUND); } -// container_field_num and container_index target a single array inside a -// repeated nested message and are meaningless individually. If only one is -// set we would otherwise either route to container_index=0 silently (when -// container_field_num is set alone) or strip the container_index off a -// top-level chunk (when container_index is set alone). Both must be flagged. TEST_F(ChunkValidationTests, RejectsContainerFieldNumWithoutContainerIndex) { auto uid = start_upload(); @@ -2168,7 +1214,6 @@ TEST_F(ChunkValidationTests, RejectsContainerIndexWithoutContainerFieldNum) TEST_F(ChunkValidationTests, AcceptsValidChunk) { auto uid = start_upload(); - // 10 doubles = 80 bytes std::string data(80, '\x42'); auto status = send_chunk(uid, cuopt::remote::FIELD_C, 0, 10, data); EXPECT_TRUE(status.ok()) << status.error_message();