From 3756aaf7bcf53d959c70b371bb331b4c571f5b45 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 28 Jul 2026 16:55:53 -0500 Subject: [PATCH 1/8] feat(c-api): add cuOptSetLogCallback and cuOptSetLogLevel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new C API functions to address #1536 and #184: - cuOptSetLogCallback(settings, callback, user_data): registers a per-line log callback. Invoked once per log line in addition to any console/file sink already enabled. Pass NULL to clear. - cuOptSetLogLevel(settings, level): overrides log verbosity using the new CUOPT_LOG_LEVEL_* constants (TRACE…OFF) in constants.h. Internally, the callback is stored in solver_settings_handle_t and installed as a rapids_logger::callback_sink_mt via a RAII scope guard in cuOptSolve, so init_logger_t picks it up without any changes to the C++ internal solver-settings structs. The pending globals are protected by the existing g_guard_mutex that already serialises init_logger_t construction. Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: Ramakrishna Prabhu --- .../mathematical_optimization/constants.h | 9 ++++ .../cuopt/mathematical_optimization/cuopt_c.h | 39 +++++++++++++++ cpp/src/pdlp/cuopt_c.cpp | 49 +++++++++++++++++++ cpp/src/utilities/logger.cpp | 45 +++++++++++++++++ cpp/src/utilities/logger.hpp | 20 ++++++++ 5 files changed, 162 insertions(+) diff --git a/cpp/include/cuopt/mathematical_optimization/constants.h b/cpp/include/cuopt/mathematical_optimization/constants.h index 9592389dea..d2ba9fa38c 100644 --- a/cpp/include/cuopt/mathematical_optimization/constants.h +++ b/cpp/include/cuopt/mathematical_optimization/constants.h @@ -146,6 +146,15 @@ /* @brief QCQP (barrier) scaling hyper-parameters */ #define CUOPT_QCQP_HYPER_RUIZ_EQUILIBRATION "qcqp_hyper_ruiz_equilibration" +/* @brief Log level constants for cuOptSetLogLevel */ +#define CUOPT_LOG_LEVEL_TRACE 0 +#define CUOPT_LOG_LEVEL_DEBUG 1 +#define CUOPT_LOG_LEVEL_INFO 2 +#define CUOPT_LOG_LEVEL_WARN 3 +#define CUOPT_LOG_LEVEL_ERROR 4 +#define CUOPT_LOG_LEVEL_CRITICAL 5 +#define CUOPT_LOG_LEVEL_OFF 6 + /* @brief MIP determinism mode constants */ #define CUOPT_MODE_OPPORTUNISTIC 0 #define CUOPT_MODE_DETERMINISTIC 1 diff --git a/cpp/include/cuopt/mathematical_optimization/cuopt_c.h b/cpp/include/cuopt/mathematical_optimization/cuopt_c.h index b135fed72a..d6382e03bc 100644 --- a/cpp/include/cuopt/mathematical_optimization/cuopt_c.h +++ b/cpp/include/cuopt/mathematical_optimization/cuopt_c.h @@ -823,6 +823,45 @@ cuopt_int_t cuOptGetFloatParameter(cuOptSolverSettings settings, const char* parameter_name, cuopt_float_t* parameter_value); +/** + * @brief Type of callback invoked once per log line emitted by the solver. + * + * @param level Log level (one of CUOPT_LOG_LEVEL_*). + * @param message Null-terminated log line without trailing newline. + * @param user_data Opaque pointer passed to cuOptSetLogCallback. + * + * @note The callback is invoked from the solver thread. Do not call back into + * cuOpt from inside the callback. + */ +typedef void (*cuOptLogCallback)(int level, const char* message, void* user_data); + +/** + * @brief Register a callback to receive solver log messages. + * + * The callback is invoked once per log line. It is called in addition to any + * file or console sink already enabled via ``log_to_console`` / ``log_file`` + * parameters. Pass NULL to remove a previously registered callback. + * + * @param[in] settings The solver settings object. + * @param[in] callback Callback function, or NULL to clear. + * @param[in] user_data Opaque pointer forwarded to the callback unchanged. + * + * @return A status code indicating success or failure. + */ +cuopt_int_t cuOptSetLogCallback(cuOptSolverSettings settings, + cuOptLogCallback callback, + void* user_data); + +/** + * @brief Set the solver log verbosity level. + * + * @param[in] settings The solver settings object. + * @param[in] level One of CUOPT_LOG_LEVEL_TRACE … CUOPT_LOG_LEVEL_OFF. + * + * @return A status code indicating success or failure. + */ +cuopt_int_t cuOptSetLogLevel(cuOptSolverSettings settings, int level); + /** * @brief Type of callback for receiving incumbent MIP solutions with user context. * diff --git a/cpp/src/pdlp/cuopt_c.cpp b/cpp/src/pdlp/cuopt_c.cpp index a813abf71f..c7556d2085 100644 --- a/cpp/src/pdlp/cuopt_c.cpp +++ b/cpp/src/pdlp/cuopt_c.cpp @@ -89,6 +89,11 @@ struct solver_settings_handle_t { ~solver_settings_handle_t() { delete settings; } solver_settings_t* settings; std::vector> callbacks; + // Log callback registered via cuOptSetLogCallback + cuOptLogCallback log_callback{nullptr}; + void* log_callback_user_data{nullptr}; + // Log level override registered via cuOptSetLogLevel (-1 = use default) + int log_level{-1}; }; solver_settings_handle_t* get_settings_handle(cuOptSolverSettings settings) @@ -1069,6 +1074,27 @@ cuopt_int_t cuOptSetMIPSetSolutionCallback(cuOptSolverSettings settings, return CUOPT_SUCCESS; } +cuopt_int_t cuOptSetLogCallback(cuOptSolverSettings settings, + cuOptLogCallback callback, + void* user_data) +{ + if (settings == nullptr) { return CUOPT_INVALID_ARGUMENT; } + solver_settings_handle_t* handle = get_settings_handle(settings); + handle->log_callback = callback; + handle->log_callback_user_data = user_data; + return CUOPT_SUCCESS; +} + +cuopt_int_t cuOptSetLogLevel(cuOptSolverSettings settings, int level) +{ + if (settings == nullptr) { return CUOPT_INVALID_ARGUMENT; } + if (level < CUOPT_LOG_LEVEL_TRACE || level > CUOPT_LOG_LEVEL_OFF) { + return CUOPT_INVALID_ARGUMENT; + } + get_settings_handle(settings)->log_level = level; + return CUOPT_SUCCESS; +} + cuopt_int_t cuOptSetInitialPrimalSolution(cuOptSolverSettings settings, const cuopt_float_t* primal_solution, cuopt_int_t num_variables) @@ -1145,6 +1171,29 @@ cuopt_int_t cuOptSolve(cuOptOptimizationProblem problem, if (settings == nullptr) { return CUOPT_INVALID_ARGUMENT; } if (solution_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } + // Install user log callback / level so init_logger_t inside the solver picks them up. + // The RAII guard clears them on scope exit (whether by return or exception). + solver_settings_handle_t* handle = get_settings_handle(settings); + struct log_scope_guard_t { + bool has_callback; + bool has_level; + ~log_scope_guard_t() + { + if (has_callback) { cuopt::clear_pending_log_callback(); } + if (has_level) { cuopt::clear_pending_log_level(); } + } + } log_scope{false, false}; + + if (handle->log_callback) { + // cuOptLogCallback and log_callback_with_data_t share the same signature. + cuopt::set_pending_log_callback(handle->log_callback, handle->log_callback_user_data); + log_scope.has_callback = true; + } + if (handle->log_level >= 0) { + cuopt::set_pending_log_level(handle->log_level); + log_scope.has_level = true; + } + problem_and_stream_view_t* problem_and_stream_view = static_cast(problem); diff --git a/cpp/src/utilities/logger.cpp b/cpp/src/utilities/logger.cpp index 217f9c64cb..f0e04d8895 100644 --- a/cpp/src/utilities/logger.cpp +++ b/cpp/src/utilities/logger.cpp @@ -146,6 +146,43 @@ struct logger_config_guard { static std::weak_ptr g_active_guard; static std::mutex g_guard_mutex; +// Pending user log callback set by the C API before a solve. +// Accessed under g_guard_mutex. +static log_callback_with_data_t g_pending_callback = nullptr; +static void* g_pending_callback_data = nullptr; +static int g_pending_log_level = -1; // -1 = use compiled default + +static void user_log_bridge(int lvl, const char* msg) +{ + if (g_pending_callback) { g_pending_callback(lvl, msg, g_pending_callback_data); } +} + +void set_pending_log_callback(log_callback_with_data_t cb, void* user_data) +{ + std::lock_guard lock(g_guard_mutex); + g_pending_callback = cb; + g_pending_callback_data = user_data; +} + +void clear_pending_log_callback() +{ + std::lock_guard lock(g_guard_mutex); + g_pending_callback = nullptr; + g_pending_callback_data = nullptr; +} + +void set_pending_log_level(int level) +{ + std::lock_guard lock(g_guard_mutex); + g_pending_log_level = level; +} + +void clear_pending_log_level() +{ + std::lock_guard lock(g_guard_mutex); + g_pending_log_level = -1; +} + init_logger_t::init_logger_t(std::string log_file, bool log_to_console) { std::lock_guard lock(g_guard_mutex); @@ -169,6 +206,10 @@ init_logger_t::init_logger_t(std::string log_file, bool log_to_console) std::make_shared(log_file, true)); cuopt::default_logger().flush_on(rapids_logger::level_enum::debug); } + if (g_pending_callback) { + cuopt::default_logger().sinks().push_back( + std::make_shared(user_log_bridge)); + } #if CUOPT_LOG_ACTIVE_LEVEL >= RAPIDS_LOGGER_LOG_LEVEL_INFO cuopt::default_logger().set_pattern("%v"); @@ -176,6 +217,10 @@ init_logger_t::init_logger_t(std::string log_file, bool log_to_console) cuopt::default_logger().set_pattern(cuopt::default_pattern()); #endif + if (g_pending_log_level >= 0) { + cuopt::default_logger().set_level(static_cast(g_pending_log_level)); + } + // Extract messages from the global buffer and log to the default logger auto buffered_messages = global_log_buffer().drain_all(); for (const auto& entry : buffered_messages) { diff --git a/cpp/src/utilities/logger.hpp b/cpp/src/utilities/logger.hpp index f3a5bf6f2f..e7ece939b9 100644 --- a/cpp/src/utilities/logger.hpp +++ b/cpp/src/utilities/logger.hpp @@ -36,6 +36,26 @@ rapids_logger::logger& default_logger(); */ void reset_default_logger(); +// C-compatible log callback type: void callback(int level, const char* msg, void* user_data) +// Matches cuOptLogCallback in cuopt_c.h — layout-compatible, no dependency on that header. +using log_callback_with_data_t = void (*)(int level, const char* message, void* user_data); + +/** + * @brief Install a user log callback to be picked up by the next init_logger_t. + * + * Must be called before the init_logger_t that starts the targeted solve. + * Protected by the same mutex as init_logger_t so it is safe to call from + * any thread, but do not call from inside the callback itself. + */ +void set_pending_log_callback(log_callback_with_data_t cb, void* user_data); +void clear_pending_log_callback(); + +/** + * @brief Override the log level for the next init_logger_t. Pass -1 to restore the default. + */ +void set_pending_log_level(int level); +void clear_pending_log_level(); + // Ref-counted logger initializer class init_logger_t { // Using shared_ptr for ref-counting From a1ce1149c7d489c048855138261b5e0c2526fe5f Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 29 Jul 2026 13:44:57 -0500 Subject: [PATCH 2/8] fix/test(c-api): fix log callback race condition and add tests Fix a data race flagged in PR review: user_log_bridge was reading g_pending_callback (a mutable global) without any lock, creating a potential race against clear_pending_log_callback() called from cuOptSolve's RAII scope guard. Fix: capture the callback + user_data immutably into the logger_config_guard at init_logger_t construction time. The bridge now reads g_active_log_callback, a stable pointer into the guard's owned state. The pointer is only nulled after reset_default_logger() removes the sink (which blocks until all in-flight bridge calls complete), so there is no race window. Also adds a @warning to cuOptLogCallback documenting that log message formatting is not a stable API and the callback is intended for display purposes, not for parsing solver events programmatically. Tests added (c_api_test.c): - log_callback: verifies callback is invoked and user_data is forwarded - log_callback_cleared: verifies NULL callback produces no calls - log_level_off: verifies CUOPT_LOG_LEVEL_OFF silences all messages - log_level_invalid: verifies out-of-range levels are rejected Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: Ramakrishna Prabhu --- .../cuopt/mathematical_optimization/cuopt_c.h | 4 + cpp/src/utilities/logger.cpp | 49 ++++-- .../c_api_tests/c_api_test.c | 155 ++++++++++++++++++ .../c_api_tests/c_api_tests.cpp | 8 + .../c_api_tests/c_api_tests.h | 4 + 5 files changed, 209 insertions(+), 11 deletions(-) diff --git a/cpp/include/cuopt/mathematical_optimization/cuopt_c.h b/cpp/include/cuopt/mathematical_optimization/cuopt_c.h index d6382e03bc..32628d63c9 100644 --- a/cpp/include/cuopt/mathematical_optimization/cuopt_c.h +++ b/cpp/include/cuopt/mathematical_optimization/cuopt_c.h @@ -832,6 +832,10 @@ cuopt_int_t cuOptGetFloatParameter(cuOptSolverSettings settings, * * @note The callback is invoked from the solver thread. Do not call back into * cuOpt from inside the callback. + * @warning Log message formatting is not part of the stable API and may change + * between releases. The callback is intended for display purposes (GUI integration, + * log forwarding, stdout capture) — do not parse message content for programmatic + * control flow. */ typedef void (*cuOptLogCallback)(int level, const char* message, void* user_data); diff --git a/cpp/src/utilities/logger.cpp b/cpp/src/utilities/logger.cpp index f0e04d8895..81e7275c0e 100644 --- a/cpp/src/utilities/logger.cpp +++ b/cpp/src/utilities/logger.cpp @@ -137,24 +137,48 @@ void reset_default_logger() default_logger().flush_on(rapids_logger::level_enum::debug); } -// Guard object whose destructor resets the logger +// Forward declarations needed by logger_config_guard destructor. +static std::mutex g_guard_mutex; +static const struct captured_log_callback_t* g_active_log_callback; + +// Captured (immutable) callback state owned by the active logger guard. +struct captured_log_callback_t { + log_callback_with_data_t callback; + void* user_data; +}; + +// Guard object whose destructor resets the logger. +// Owns the captured callback state to guarantee its lifetime. struct logger_config_guard { - ~logger_config_guard() { cuopt::reset_default_logger(); } + std::unique_ptr callback_state; + ~logger_config_guard() + { + cuopt::reset_default_logger(); // removes the sink; blocks until in-flight log calls finish + std::lock_guard lock(g_guard_mutex); + g_active_log_callback = nullptr; // safe: the sink (and the bridge) are already gone + } }; // Weak reference to detect if any init_logger_t instance is still alive static std::weak_ptr g_active_guard; -static std::mutex g_guard_mutex; -// Pending user log callback set by the C API before a solve. -// Accessed under g_guard_mutex. -static log_callback_with_data_t g_pending_callback = nullptr; -static void* g_pending_callback_data = nullptr; -static int g_pending_log_level = -1; // -1 = use compiled default +// g_active_log_callback: written only under g_guard_mutex (at guard create/destroy time). +// Read lock-free by user_log_bridge — safe because the bridge is only reachable +// while the sink is alive, and the sink is removed (in reset_default_logger) before +// this pointer is cleared. + +// Pending user log callback/level set by the C API before cuOptSolve. +// Consumed once (under g_guard_mutex) by init_logger_t to build the guard state. +static log_callback_with_data_t g_pending_callback = nullptr; +static void* g_pending_callback_data = nullptr; +static int g_pending_log_level = -1; // -1 = use compiled default static void user_log_bridge(int lvl, const char* msg) { - if (g_pending_callback) { g_pending_callback(lvl, msg, g_pending_callback_data); } + // g_active_log_callback is stable for the duration of any bridge call: + // it points into the guard's callback_state, which outlives the sink. + const captured_log_callback_t* state = g_active_log_callback; + if (state) { state->callback(lvl, msg, state->user_data); } } void set_pending_log_callback(log_callback_with_data_t cb, void* user_data) @@ -206,7 +230,12 @@ init_logger_t::init_logger_t(std::string log_file, bool log_to_console) std::make_shared(log_file, true)); cuopt::default_logger().flush_on(rapids_logger::level_enum::debug); } + // Capture pending callback into the guard so the bridge reads stable (immutable) state. + auto guard = std::make_shared(); if (g_pending_callback) { + guard->callback_state = + std::make_unique(captured_log_callback_t{g_pending_callback, g_pending_callback_data}); + g_active_log_callback = guard->callback_state.get(); cuopt::default_logger().sinks().push_back( std::make_shared(user_log_bridge)); } @@ -227,8 +256,6 @@ init_logger_t::init_logger_t(std::string log_file, bool log_to_console) cuopt::default_logger().log(entry.level, entry.msg.c_str()); } - // Create guard and store weak reference for future instances to find - auto guard = std::make_shared(); g_active_guard = guard; guard_ = guard; } diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_test.c b/cpp/tests/linear_programming/c_api_tests/c_api_test.c index 31bd18d0ef..8c77016835 100644 --- a/cpp/tests/linear_programming/c_api_tests/c_api_test.c +++ b/cpp/tests/linear_programming/c_api_tests/c_api_test.c @@ -292,6 +292,161 @@ cuopt_int_t test_mip_get_callbacks_only() { return test_mip_callbacks_internal(0 cuopt_int_t test_mip_get_set_callbacks() { return test_mip_callbacks_internal(1); } +/* ------------------------------------------------------------------------- + * Log callback tests + * Use a small LP (1 variable, 1 constraint) so no GPU / dataset is needed. + * ------------------------------------------------------------------------- */ + +/* Build a trivial 1-variable LP: min x s.t. x >= 1, 0 <= x <= inf */ +static cuopt_int_t make_trivial_lp(cuOptOptimizationProblem* problem_out, + cuOptSolverSettings* settings_out) +{ + cuopt_float_t obj[] = {1.0}; + cuopt_int_t row_off[] = {0, 1}; + cuopt_int_t col_idx[] = {0}; + cuopt_float_t coeff[] = {1.0}; + char sense[] = {CUOPT_GREATER_THAN}; + cuopt_float_t rhs[] = {1.0}; + cuopt_float_t lb[] = {0.0}; + cuopt_float_t ub[] = {1e30}; + char vtype[] = {CUOPT_CONTINUOUS}; + + cuopt_int_t status = + cuOptCreateProblem(1, 1, CUOPT_MINIMIZE, 0.0, obj, row_off, col_idx, coeff, + sense, rhs, lb, ub, vtype, problem_out); + if (status != CUOPT_SUCCESS) return status; + return cuOptCreateSolverSettings(settings_out); +} + +typedef struct { + int calls; + void* received_user_data; +} log_cb_context_t; + +static void counting_log_callback(int level, const char* message, void* user_data) +{ + (void)level; + (void)message; + log_cb_context_t* ctx = (log_cb_context_t*)user_data; + ctx->calls++; + ctx->received_user_data = user_data; +} + +cuopt_int_t test_log_callback(void) +{ + cuOptOptimizationProblem problem = NULL; + cuOptSolverSettings settings = NULL; + cuOptSolution solution = NULL; + log_cb_context_t ctx = {0, NULL}; + cuopt_int_t status = make_trivial_lp(&problem, &settings); + if (status != CUOPT_SUCCESS) goto DONE; + + status = cuOptSetLogCallback(settings, counting_log_callback, &ctx); + if (status != CUOPT_SUCCESS) goto DONE; + + status = cuOptSolve(problem, settings, &solution); + if (status != CUOPT_SUCCESS) goto DONE; + + if (ctx.calls < 1) { + printf("Expected log callback to be called at least once; got %d calls\n", ctx.calls); + status = CUOPT_INVALID_ARGUMENT; + goto DONE; + } + if (ctx.received_user_data != &ctx) { + printf("user_data pointer was not forwarded correctly\n"); + status = CUOPT_INVALID_ARGUMENT; + goto DONE; + } + +DONE: + cuOptDestroyProblem(&problem); + cuOptDestroySolverSettings(&settings); + cuOptDestroySolution(&solution); + return status; +} + +cuopt_int_t test_log_callback_cleared(void) +{ + cuOptOptimizationProblem problem = NULL; + cuOptSolverSettings settings = NULL; + cuOptSolution solution = NULL; + log_cb_context_t ctx = {0, NULL}; + cuopt_int_t status = make_trivial_lp(&problem, &settings); + if (status != CUOPT_SUCCESS) goto DONE; + + /* Register then immediately clear the callback */ + status = cuOptSetLogCallback(settings, counting_log_callback, &ctx); + if (status != CUOPT_SUCCESS) goto DONE; + status = cuOptSetLogCallback(settings, NULL, NULL); + if (status != CUOPT_SUCCESS) goto DONE; + + status = cuOptSolve(problem, settings, &solution); + if (status != CUOPT_SUCCESS) goto DONE; + + if (ctx.calls != 0) { + printf("Expected 0 callback calls after clearing; got %d\n", ctx.calls); + status = CUOPT_INVALID_ARGUMENT; + goto DONE; + } + +DONE: + cuOptDestroyProblem(&problem); + cuOptDestroySolverSettings(&settings); + cuOptDestroySolution(&solution); + return status; +} + +cuopt_int_t test_log_level_off(void) +{ + cuOptOptimizationProblem problem = NULL; + cuOptSolverSettings settings = NULL; + cuOptSolution solution = NULL; + log_cb_context_t ctx = {0, NULL}; + cuopt_int_t status = make_trivial_lp(&problem, &settings); + if (status != CUOPT_SUCCESS) goto DONE; + + status = cuOptSetLogCallback(settings, counting_log_callback, &ctx); + if (status != CUOPT_SUCCESS) goto DONE; + status = cuOptSetLogLevel(settings, CUOPT_LOG_LEVEL_OFF); + if (status != CUOPT_SUCCESS) goto DONE; + + status = cuOptSolve(problem, settings, &solution); + if (status != CUOPT_SUCCESS) goto DONE; + + if (ctx.calls != 0) { + printf("Expected 0 log calls with LOG_LEVEL_OFF; got %d\n", ctx.calls); + status = CUOPT_INVALID_ARGUMENT; + goto DONE; + } + +DONE: + cuOptDestroyProblem(&problem); + cuOptDestroySolverSettings(&settings); + cuOptDestroySolution(&solution); + return status; +} + +cuopt_int_t test_log_level_invalid(void) +{ + cuOptSolverSettings settings = NULL; + cuopt_int_t status = cuOptCreateSolverSettings(&settings); + if (status != CUOPT_SUCCESS) goto DONE; + + if (cuOptSetLogLevel(settings, -1) != CUOPT_INVALID_ARGUMENT) { + status = CUOPT_INVALID_ARGUMENT; + goto DONE; + } + if (cuOptSetLogLevel(settings, CUOPT_LOG_LEVEL_OFF + 1) != CUOPT_INVALID_ARGUMENT) { + status = CUOPT_INVALID_ARGUMENT; + goto DONE; + } + status = CUOPT_SUCCESS; + +DONE: + cuOptDestroySolverSettings(&settings); + return status; +} + cuopt_int_t burglar_problem() { cuOptOptimizationProblem problem = NULL; diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp b/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp index ed0e017cae..54a031646a 100644 --- a/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp +++ b/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp @@ -114,6 +114,14 @@ TEST(c_api, mip_get_callbacks_only) { EXPECT_EQ(test_mip_get_callbacks_only(), C TEST(c_api, mip_get_set_callbacks) { EXPECT_EQ(test_mip_get_set_callbacks(), CUOPT_SUCCESS); } +TEST(c_api, log_callback) { EXPECT_EQ(test_log_callback(), CUOPT_SUCCESS); } + +TEST(c_api, log_callback_cleared) { EXPECT_EQ(test_log_callback_cleared(), CUOPT_SUCCESS); } + +TEST(c_api, log_level_off) { EXPECT_EQ(test_log_level_off(), CUOPT_SUCCESS); } + +TEST(c_api, log_level_invalid) { EXPECT_EQ(test_log_level_invalid(), CUOPT_SUCCESS); } + TEST(c_api, burglar) { EXPECT_EQ(burglar_problem(), CUOPT_SUCCESS); } TEST(c_api, test_missing_file) { EXPECT_EQ(test_missing_file(), CUOPT_MPS_FILE_ERROR); } diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_tests.h b/cpp/tests/linear_programming/c_api_tests/c_api_tests.h index a8c3a1f4e4..b754fb0147 100644 --- a/cpp/tests/linear_programming/c_api_tests/c_api_tests.h +++ b/cpp/tests/linear_programming/c_api_tests/c_api_tests.h @@ -30,6 +30,10 @@ cuopt_int_t test_infeasible_problem(); cuopt_int_t test_bad_parameter_name(); cuopt_int_t test_mip_get_callbacks_only(); cuopt_int_t test_mip_get_set_callbacks(); +cuopt_int_t test_log_callback(); +cuopt_int_t test_log_callback_cleared(); +cuopt_int_t test_log_level_off(); +cuopt_int_t test_log_level_invalid(); cuopt_int_t test_ranged_problem(cuopt_int_t* termination_status_ptr, cuopt_float_t* objective_ptr); cuopt_int_t test_semi_continuous_problem(cuopt_int_t* termination_status_ptr, cuopt_float_t* objective_ptr, From adc2f812542e63ad10f2dae162892e5d94efad51 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 30 Jul 2026 10:30:00 -0500 Subject: [PATCH 3/8] refactor(c-api): drop cuOptSetLogLevel, control log level via env var Per review (chris-maes): log levels are developer/debug-facing and should not be part of the public C API. Remove cuOptSetLogLevel and the public CUOPT_LOG_LEVEL_* constants; keep cuOptSetLogCallback. Verbosity is now controlled at runtime by the CUOPT_LOG_LEVEL environment variable (TRACE..OFF, case-insensitive), read in default_level(). - Point two mip_heuristics #if guards at RAPIDS_LOGGER_LOG_LEVEL_* instead of the removed CUOPT_LOG_LEVEL_*, fixing a pre-existing latent bug where those undefined macros evaluated to 0. - Replace the log-level unit tests with a cross-solve callback-lifecycle regression (CodeRabbit). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Ramakrishna Prabhu --- .../mathematical_optimization/constants.h | 9 --- .../cuopt/mathematical_optimization/cuopt_c.h | 14 +--- .../diversity/diversity_manager.cu | 2 +- .../feasibility_jump/feasibility_jump.cu | 2 +- cpp/src/pdlp/cuopt_c.cpp | 28 ++------ cpp/src/utilities/logger.cpp | 61 +++++++++++------ cpp/src/utilities/logger.hpp | 6 -- .../c_api_tests/c_api_test.c | 65 +++++++++---------- .../c_api_tests/c_api_tests.cpp | 7 +- .../c_api_tests/c_api_tests.h | 3 +- 10 files changed, 86 insertions(+), 111 deletions(-) diff --git a/cpp/include/cuopt/mathematical_optimization/constants.h b/cpp/include/cuopt/mathematical_optimization/constants.h index d2ba9fa38c..9592389dea 100644 --- a/cpp/include/cuopt/mathematical_optimization/constants.h +++ b/cpp/include/cuopt/mathematical_optimization/constants.h @@ -146,15 +146,6 @@ /* @brief QCQP (barrier) scaling hyper-parameters */ #define CUOPT_QCQP_HYPER_RUIZ_EQUILIBRATION "qcqp_hyper_ruiz_equilibration" -/* @brief Log level constants for cuOptSetLogLevel */ -#define CUOPT_LOG_LEVEL_TRACE 0 -#define CUOPT_LOG_LEVEL_DEBUG 1 -#define CUOPT_LOG_LEVEL_INFO 2 -#define CUOPT_LOG_LEVEL_WARN 3 -#define CUOPT_LOG_LEVEL_ERROR 4 -#define CUOPT_LOG_LEVEL_CRITICAL 5 -#define CUOPT_LOG_LEVEL_OFF 6 - /* @brief MIP determinism mode constants */ #define CUOPT_MODE_OPPORTUNISTIC 0 #define CUOPT_MODE_DETERMINISTIC 1 diff --git a/cpp/include/cuopt/mathematical_optimization/cuopt_c.h b/cpp/include/cuopt/mathematical_optimization/cuopt_c.h index 32628d63c9..ffc296072c 100644 --- a/cpp/include/cuopt/mathematical_optimization/cuopt_c.h +++ b/cpp/include/cuopt/mathematical_optimization/cuopt_c.h @@ -826,7 +826,9 @@ cuopt_int_t cuOptGetFloatParameter(cuOptSolverSettings settings, /** * @brief Type of callback invoked once per log line emitted by the solver. * - * @param level Log level (one of CUOPT_LOG_LEVEL_*). + * @param level Severity of the log line, increasing with value + * (0=trace, 1=debug, 2=info, 3=warn, 4=error, 5=critical). Intended only for + * display/filtering of output, not as a stable programmatic API. * @param message Null-terminated log line without trailing newline. * @param user_data Opaque pointer passed to cuOptSetLogCallback. * @@ -856,16 +858,6 @@ cuopt_int_t cuOptSetLogCallback(cuOptSolverSettings settings, cuOptLogCallback callback, void* user_data); -/** - * @brief Set the solver log verbosity level. - * - * @param[in] settings The solver settings object. - * @param[in] level One of CUOPT_LOG_LEVEL_TRACE … CUOPT_LOG_LEVEL_OFF. - * - * @return A status code indicating success or failure. - */ -cuopt_int_t cuOptSetLogLevel(cuOptSolverSettings settings, int level); - /** * @brief Type of callback for receiving incumbent MIP solutions with user context. * diff --git a/cpp/src/mip_heuristics/diversity/diversity_manager.cu b/cpp/src/mip_heuristics/diversity/diversity_manager.cu index 61c90944f4..96b53c5ba9 100644 --- a/cpp/src/mip_heuristics/diversity/diversity_manager.cu +++ b/cpp/src/mip_heuristics/diversity/diversity_manager.cu @@ -207,7 +207,7 @@ void diversity_manager_t::add_user_given_solutions( *problem_ptr->original_problem_ptr, h_original, h_crushed); init_sol_assignment = cuopt::device_copy(h_crushed, sol.handle_ptr->get_stream()); -#if CUOPT_LOG_ACTIVE_LEVEL <= CUOPT_LOG_LEVEL_DEBUG +#if CUOPT_LOG_ACTIVE_LEVEL <= RAPIDS_LOGGER_LOG_LEVEL_DEBUG const auto& reduced_problem = *problem_ptr->original_problem_ptr; const std::vector h_red_obj = reduced_problem.get_objective_coefficients_host(); const std::vector& h_ori_obj = presolver_ptr->get_original_objective_coefficients(); diff --git a/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump.cu b/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump.cu index a6665e57e1..44fd71cb92 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump.cu @@ -945,7 +945,7 @@ i_t fj_t::host_loop(solution_t& solution, i_t climber_idx) } } } -#if CUOPT_LOG_ACTIVE_LEVEL == CUOPT_LOG_LEVEL_TRACE +#if CUOPT_LOG_ACTIVE_LEVEL == RAPIDS_LOGGER_LOG_LEVEL_TRACE auto h_sol = cuopt::host_copy(solution.assignment, climber_stream); static std::set> solutions_set; bool same_sol = solutions_set.count(h_sol) > 0; diff --git a/cpp/src/pdlp/cuopt_c.cpp b/cpp/src/pdlp/cuopt_c.cpp index c7556d2085..7de24deb13 100644 --- a/cpp/src/pdlp/cuopt_c.cpp +++ b/cpp/src/pdlp/cuopt_c.cpp @@ -92,8 +92,6 @@ struct solver_settings_handle_t { // Log callback registered via cuOptSetLogCallback cuOptLogCallback log_callback{nullptr}; void* log_callback_user_data{nullptr}; - // Log level override registered via cuOptSetLogLevel (-1 = use default) - int log_level{-1}; }; solver_settings_handle_t* get_settings_handle(cuOptSolverSettings settings) @@ -1075,8 +1073,8 @@ cuopt_int_t cuOptSetMIPSetSolutionCallback(cuOptSolverSettings settings, } cuopt_int_t cuOptSetLogCallback(cuOptSolverSettings settings, - cuOptLogCallback callback, - void* user_data) + cuOptLogCallback callback, + void* user_data) { if (settings == nullptr) { return CUOPT_INVALID_ARGUMENT; } solver_settings_handle_t* handle = get_settings_handle(settings); @@ -1085,16 +1083,6 @@ cuopt_int_t cuOptSetLogCallback(cuOptSolverSettings settings, return CUOPT_SUCCESS; } -cuopt_int_t cuOptSetLogLevel(cuOptSolverSettings settings, int level) -{ - if (settings == nullptr) { return CUOPT_INVALID_ARGUMENT; } - if (level < CUOPT_LOG_LEVEL_TRACE || level > CUOPT_LOG_LEVEL_OFF) { - return CUOPT_INVALID_ARGUMENT; - } - get_settings_handle(settings)->log_level = level; - return CUOPT_SUCCESS; -} - cuopt_int_t cuOptSetInitialPrimalSolution(cuOptSolverSettings settings, const cuopt_float_t* primal_solution, cuopt_int_t num_variables) @@ -1171,28 +1159,22 @@ cuopt_int_t cuOptSolve(cuOptOptimizationProblem problem, if (settings == nullptr) { return CUOPT_INVALID_ARGUMENT; } if (solution_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } - // Install user log callback / level so init_logger_t inside the solver picks them up. - // The RAII guard clears them on scope exit (whether by return or exception). + // Install user log callback so init_logger_t inside the solver picks it up. + // The RAII guard clears it on scope exit (whether by return or exception). solver_settings_handle_t* handle = get_settings_handle(settings); struct log_scope_guard_t { bool has_callback; - bool has_level; ~log_scope_guard_t() { if (has_callback) { cuopt::clear_pending_log_callback(); } - if (has_level) { cuopt::clear_pending_log_level(); } } - } log_scope{false, false}; + } log_scope{false}; if (handle->log_callback) { // cuOptLogCallback and log_callback_with_data_t share the same signature. cuopt::set_pending_log_callback(handle->log_callback, handle->log_callback_user_data); log_scope.has_callback = true; } - if (handle->log_level >= 0) { - cuopt::set_pending_log_level(handle->log_level); - log_scope.has_level = true; - } problem_and_stream_view_t* problem_and_stream_view = static_cast(problem); diff --git a/cpp/src/utilities/logger.cpp b/cpp/src/utilities/logger.cpp index 81e7275c0e..8251da7941 100644 --- a/cpp/src/utilities/logger.cpp +++ b/cpp/src/utilities/logger.cpp @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -8,6 +8,11 @@ #include #include +#include +#include +#include +#include + namespace cuopt { struct buffered_entry { @@ -82,13 +87,44 @@ rapids_logger::sink_ptr default_sink() */ inline std::string default_pattern() { return "[%Y-%m-%d %H:%M:%S:%f] [%n] [%-6l] %v"; } +/** + * @brief Runtime log-level override from the `CUOPT_LOG_LEVEL` environment variable. + * + * Accepts a level name (case-insensitive): TRACE, DEBUG, INFO, WARN, ERROR, CRITICAL, OFF. + * Returns std::nullopt if the variable is unset or holds an unrecognised value. + * + * @note Statements below the compile-time `CUOPT_LOG_ACTIVE_LEVEL` (default INFO) are + * removed at build time, so raising verbosity above the build level has no effect; + * lowering it (e.g. WARN/ERROR/OFF to suppress output) always works. + */ +inline std::optional env_log_level() +{ + const char* env = std::getenv("CUOPT_LOG_LEVEL"); + if (env == nullptr) { return std::nullopt; } + std::string level{env}; + std::transform(level.begin(), level.end(), level.begin(), [](unsigned char c) { + return static_cast(std::toupper(c)); + }); + if (level == "TRACE") { return rapids_logger::level_enum::trace; } + if (level == "DEBUG") { return rapids_logger::level_enum::debug; } + if (level == "INFO") { return rapids_logger::level_enum::info; } + if (level == "WARN") { return rapids_logger::level_enum::warn; } + if (level == "ERROR") { return rapids_logger::level_enum::error; } + if (level == "CRITICAL") { return rapids_logger::level_enum::critical; } + if (level == "OFF") { return rapids_logger::level_enum::off; } + return std::nullopt; // unrecognised value: keep the compiled default +} + /** * @brief Returns the default log level for the global logger. * + * The `CUOPT_LOG_LEVEL` environment variable, when set, overrides the compile-time default. + * * @return rapids_logger::level_enum The default log level. */ inline rapids_logger::level_enum default_level() { + if (auto lvl = env_log_level()) { return *lvl; } #if CUOPT_LOG_ACTIVE_LEVEL == RAPIDS_LOGGER_LOG_LEVEL_TRACE return rapids_logger::level_enum::trace; #elif CUOPT_LOG_ACTIVE_LEVEL == RAPIDS_LOGGER_LOG_LEVEL_DEBUG @@ -167,11 +203,10 @@ static std::weak_ptr g_active_guard; // while the sink is alive, and the sink is removed (in reset_default_logger) before // this pointer is cleared. -// Pending user log callback/level set by the C API before cuOptSolve. +// Pending user log callback set by the C API before cuOptSolve. // Consumed once (under g_guard_mutex) by init_logger_t to build the guard state. static log_callback_with_data_t g_pending_callback = nullptr; static void* g_pending_callback_data = nullptr; -static int g_pending_log_level = -1; // -1 = use compiled default static void user_log_bridge(int lvl, const char* msg) { @@ -195,18 +230,6 @@ void clear_pending_log_callback() g_pending_callback_data = nullptr; } -void set_pending_log_level(int level) -{ - std::lock_guard lock(g_guard_mutex); - g_pending_log_level = level; -} - -void clear_pending_log_level() -{ - std::lock_guard lock(g_guard_mutex); - g_pending_log_level = -1; -} - init_logger_t::init_logger_t(std::string log_file, bool log_to_console) { std::lock_guard lock(g_guard_mutex); @@ -233,8 +256,8 @@ init_logger_t::init_logger_t(std::string log_file, bool log_to_console) // Capture pending callback into the guard so the bridge reads stable (immutable) state. auto guard = std::make_shared(); if (g_pending_callback) { - guard->callback_state = - std::make_unique(captured_log_callback_t{g_pending_callback, g_pending_callback_data}); + guard->callback_state = std::make_unique( + captured_log_callback_t{g_pending_callback, g_pending_callback_data}); g_active_log_callback = guard->callback_state.get(); cuopt::default_logger().sinks().push_back( std::make_shared(user_log_bridge)); @@ -246,10 +269,6 @@ init_logger_t::init_logger_t(std::string log_file, bool log_to_console) cuopt::default_logger().set_pattern(cuopt::default_pattern()); #endif - if (g_pending_log_level >= 0) { - cuopt::default_logger().set_level(static_cast(g_pending_log_level)); - } - // Extract messages from the global buffer and log to the default logger auto buffered_messages = global_log_buffer().drain_all(); for (const auto& entry : buffered_messages) { diff --git a/cpp/src/utilities/logger.hpp b/cpp/src/utilities/logger.hpp index e7ece939b9..8947e6aa76 100644 --- a/cpp/src/utilities/logger.hpp +++ b/cpp/src/utilities/logger.hpp @@ -50,12 +50,6 @@ using log_callback_with_data_t = void (*)(int level, const char* message, void* void set_pending_log_callback(log_callback_with_data_t cb, void* user_data); void clear_pending_log_callback(); -/** - * @brief Override the log level for the next init_logger_t. Pass -1 to restore the default. - */ -void set_pending_log_level(int level); -void clear_pending_log_level(); - // Ref-counted logger initializer class init_logger_t { // Using shared_ptr for ref-counting diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_test.c b/cpp/tests/linear_programming/c_api_tests/c_api_test.c index 8c77016835..5acc1ebc69 100644 --- a/cpp/tests/linear_programming/c_api_tests/c_api_test.c +++ b/cpp/tests/linear_programming/c_api_tests/c_api_test.c @@ -396,54 +396,51 @@ cuopt_int_t test_log_callback_cleared(void) return status; } -cuopt_int_t test_log_level_off(void) +/* A callback registered for one solve must not fire on a later solve that uses + * fresh settings with no callback — otherwise a stale callback could run against + * destroyed user_data once the first solve's RAII scope ends. */ +cuopt_int_t test_log_callback_not_leaked_across_solves(void) { - cuOptOptimizationProblem problem = NULL; - cuOptSolverSettings settings = NULL; - cuOptSolution solution = NULL; - log_cb_context_t ctx = {0, NULL}; - cuopt_int_t status = make_trivial_lp(&problem, &settings); + cuOptOptimizationProblem problem1 = NULL; + cuOptSolverSettings settings1 = NULL; + cuOptSolution solution1 = NULL; + cuOptOptimizationProblem problem2 = NULL; + cuOptSolverSettings settings2 = NULL; + cuOptSolution solution2 = NULL; + log_cb_context_t ctx = {0, NULL}; + int calls_after_first = 0; + + cuopt_int_t status = make_trivial_lp(&problem1, &settings1); if (status != CUOPT_SUCCESS) goto DONE; - status = cuOptSetLogCallback(settings, counting_log_callback, &ctx); - if (status != CUOPT_SUCCESS) goto DONE; - status = cuOptSetLogLevel(settings, CUOPT_LOG_LEVEL_OFF); + status = cuOptSetLogCallback(settings1, counting_log_callback, &ctx); if (status != CUOPT_SUCCESS) goto DONE; - - status = cuOptSolve(problem, settings, &solution); + status = cuOptSolve(problem1, settings1, &solution1); if (status != CUOPT_SUCCESS) goto DONE; - if (ctx.calls != 0) { - printf("Expected 0 log calls with LOG_LEVEL_OFF; got %d\n", ctx.calls); - status = CUOPT_INVALID_ARGUMENT; - goto DONE; - } - -DONE: - cuOptDestroyProblem(&problem); - cuOptDestroySolverSettings(&settings); - cuOptDestroySolution(&solution); - return status; -} + calls_after_first = ctx.calls; -cuopt_int_t test_log_level_invalid(void) -{ - cuOptSolverSettings settings = NULL; - cuopt_int_t status = cuOptCreateSolverSettings(&settings); + /* Second solve uses fresh settings with no callback registered. */ + status = make_trivial_lp(&problem2, &settings2); + if (status != CUOPT_SUCCESS) goto DONE; + status = cuOptSolve(problem2, settings2, &solution2); if (status != CUOPT_SUCCESS) goto DONE; - if (cuOptSetLogLevel(settings, -1) != CUOPT_INVALID_ARGUMENT) { + if (ctx.calls != calls_after_first) { + printf("Callback leaked across solves; expected %d calls, got %d\n", + calls_after_first, + ctx.calls); status = CUOPT_INVALID_ARGUMENT; goto DONE; } - if (cuOptSetLogLevel(settings, CUOPT_LOG_LEVEL_OFF + 1) != CUOPT_INVALID_ARGUMENT) { - status = CUOPT_INVALID_ARGUMENT; - goto DONE; - } - status = CUOPT_SUCCESS; DONE: - cuOptDestroySolverSettings(&settings); + cuOptDestroyProblem(&problem1); + cuOptDestroySolverSettings(&settings1); + cuOptDestroySolution(&solution1); + cuOptDestroyProblem(&problem2); + cuOptDestroySolverSettings(&settings2); + cuOptDestroySolution(&solution2); return status; } diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp b/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp index 54a031646a..3575865709 100644 --- a/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp +++ b/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp @@ -118,9 +118,10 @@ TEST(c_api, log_callback) { EXPECT_EQ(test_log_callback(), CUOPT_SUCCESS); } TEST(c_api, log_callback_cleared) { EXPECT_EQ(test_log_callback_cleared(), CUOPT_SUCCESS); } -TEST(c_api, log_level_off) { EXPECT_EQ(test_log_level_off(), CUOPT_SUCCESS); } - -TEST(c_api, log_level_invalid) { EXPECT_EQ(test_log_level_invalid(), CUOPT_SUCCESS); } +TEST(c_api, log_callback_not_leaked_across_solves) +{ + EXPECT_EQ(test_log_callback_not_leaked_across_solves(), CUOPT_SUCCESS); +} TEST(c_api, burglar) { EXPECT_EQ(burglar_problem(), CUOPT_SUCCESS); } diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_tests.h b/cpp/tests/linear_programming/c_api_tests/c_api_tests.h index b754fb0147..5cde8c3915 100644 --- a/cpp/tests/linear_programming/c_api_tests/c_api_tests.h +++ b/cpp/tests/linear_programming/c_api_tests/c_api_tests.h @@ -32,8 +32,7 @@ cuopt_int_t test_mip_get_callbacks_only(); cuopt_int_t test_mip_get_set_callbacks(); cuopt_int_t test_log_callback(); cuopt_int_t test_log_callback_cleared(); -cuopt_int_t test_log_level_off(); -cuopt_int_t test_log_level_invalid(); +cuopt_int_t test_log_callback_not_leaked_across_solves(); cuopt_int_t test_ranged_problem(cuopt_int_t* termination_status_ptr, cuopt_float_t* objective_ptr); cuopt_int_t test_semi_continuous_problem(cuopt_int_t* termination_status_ptr, cuopt_float_t* objective_ptr, From dc14e3bdfe0111ab9b50221eb55e2662d666ee3d Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 19 Aug 2026 16:48:34 -0500 Subject: [PATCH 4/8] feat(c-api): drop the log level from the callback, deliver standard output only Addresses review feedback from @chris-maes: log levels should not be exposed through the API, and the callback should fire only on a standard log line. The level parameter is gone from cuOptLogCallback: -typedef void (*cuOptLogCallback)(int level, const char* message, void* user_data); +typedef void (*cuOptLogCallback)(const char* message, void* user_data); There is now no severity anywhere in the C surface -- no enum, no numeric mapping, nothing for callers to branch on. The previous signature tried to solve this with a doc comment ("not a stable programmatic API"), but a disclaimer does not stop code from being written against a value it can see. The bridge also filters below info, so debug and trace never reach user code. That previously held only by accident of the build: CUOPT_LOG_ACTIVE_LEVEL defaults to INFO so those statements are compiled out, but a lower-level build -- or CUOPT_LOG_LEVEL=DEBUG against one -- would have routed internal diagnostics straight into a user callback. Filtering in the bridge makes it a property of the API instead. Note on scope: cuOpt's standard solver output is emitted at info (CUOPT_LOG_INFO covers "Solve status", "PDLP finished" and the progress table, ~153 call sites), so "no info messages" cannot be taken literally without silencing the solver log entirely. The cut is therefore at info: users receive what the console would show, and nothing below it. Splitting info into user-facing output vs internal diagnostics would be a larger change across those call sites. --- .../cuopt/mathematical_optimization/cuopt_c.h | 12 +++++++----- cpp/src/utilities/logger.cpp | 10 +++++++++- cpp/src/utilities/logger.hpp | 8 ++++++-- .../linear_programming/c_api_tests/c_api_test.c | 3 +-- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/cpp/include/cuopt/mathematical_optimization/cuopt_c.h b/cpp/include/cuopt/mathematical_optimization/cuopt_c.h index ffc296072c..3192446f33 100644 --- a/cpp/include/cuopt/mathematical_optimization/cuopt_c.h +++ b/cpp/include/cuopt/mathematical_optimization/cuopt_c.h @@ -824,11 +824,13 @@ cuopt_int_t cuOptGetFloatParameter(cuOptSolverSettings settings, cuopt_float_t* parameter_value); /** - * @brief Type of callback invoked once per log line emitted by the solver. + * @brief Type of callback invoked once per standard solver log line. + * + * Receives the same lines the solver would print to the console — nothing more. + * Internal diagnostics (debug and trace messages) are never delivered, and no + * severity is reported: the callback exists to display or forward solver + * output, not to let callers classify or branch on it. * - * @param level Severity of the log line, increasing with value - * (0=trace, 1=debug, 2=info, 3=warn, 4=error, 5=critical). Intended only for - * display/filtering of output, not as a stable programmatic API. * @param message Null-terminated log line without trailing newline. * @param user_data Opaque pointer passed to cuOptSetLogCallback. * @@ -839,7 +841,7 @@ cuopt_int_t cuOptGetFloatParameter(cuOptSolverSettings settings, * log forwarding, stdout capture) — do not parse message content for programmatic * control flow. */ -typedef void (*cuOptLogCallback)(int level, const char* message, void* user_data); +typedef void (*cuOptLogCallback)(const char* message, void* user_data); /** * @brief Register a callback to receive solver log messages. diff --git a/cpp/src/utilities/logger.cpp b/cpp/src/utilities/logger.cpp index 8251da7941..048f206f16 100644 --- a/cpp/src/utilities/logger.cpp +++ b/cpp/src/utilities/logger.cpp @@ -210,10 +210,18 @@ static void* g_pending_callback_data = nullptr; static void user_log_bridge(int lvl, const char* msg) { + // Deliver only standard solver output — the lines a user would see on the + // console. Debug and trace are internal diagnostics: they are normally + // compiled out (CUOPT_LOG_ACTIVE_LEVEL defaults to INFO), but a build with a + // lower level, or CUOPT_LOG_LEVEL=DEBUG against such a build, would otherwise + // route them into user code. Filtering here makes the guarantee a property of + // the API rather than of the build configuration. + if (lvl < static_cast(rapids_logger::level_enum::info)) { return; } + // g_active_log_callback is stable for the duration of any bridge call: // it points into the guard's callback_state, which outlives the sink. const captured_log_callback_t* state = g_active_log_callback; - if (state) { state->callback(lvl, msg, state->user_data); } + if (state) { state->callback(msg, state->user_data); } } void set_pending_log_callback(log_callback_with_data_t cb, void* user_data) diff --git a/cpp/src/utilities/logger.hpp b/cpp/src/utilities/logger.hpp index 8947e6aa76..8a45a8db45 100644 --- a/cpp/src/utilities/logger.hpp +++ b/cpp/src/utilities/logger.hpp @@ -36,9 +36,13 @@ rapids_logger::logger& default_logger(); */ void reset_default_logger(); -// C-compatible log callback type: void callback(int level, const char* msg, void* user_data) +// C-compatible log callback type: void callback(const char* msg, void* user_data) // Matches cuOptLogCallback in cuopt_c.h — layout-compatible, no dependency on that header. -using log_callback_with_data_t = void (*)(int level, const char* message, void* user_data); +// +// Deliberately carries no severity. Exposing a level would let callers branch on +// it, which makes our internal log taxonomy a de-facto public API; the callback +// is for displaying or forwarding standard solver output only. +using log_callback_with_data_t = void (*)(const char* message, void* user_data); /** * @brief Install a user log callback to be picked up by the next init_logger_t. diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_test.c b/cpp/tests/linear_programming/c_api_tests/c_api_test.c index 5acc1ebc69..758f239dfc 100644 --- a/cpp/tests/linear_programming/c_api_tests/c_api_test.c +++ b/cpp/tests/linear_programming/c_api_tests/c_api_test.c @@ -323,9 +323,8 @@ typedef struct { void* received_user_data; } log_cb_context_t; -static void counting_log_callback(int level, const char* message, void* user_data) +static void counting_log_callback(const char* message, void* user_data) { - (void)level; (void)message; log_cb_context_t* ctx = (log_cb_context_t*)user_data; ctx->calls++; From 351decbd219800601645d5aee602060ff660f54b Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 19 Aug 2026 17:32:07 -0500 Subject: [PATCH 5/8] fix(c-api): consume the pending log callback exactly once init_logger_t returns early when a guard is already alive, so a callback registered for that solve was never installed -- but stayed in the process-global pending slot. A later solve that did build a guard would then adopt it, invoking the function pointer with user_data belonging to a solve that may already have returned and freed it. Clear the slot on both paths: on the early return (the registration can never be installed) and after capture (the guard owns a copy). This removes the stale-pointer hazard. It does not make callbacks solve-local: a concurrent solve's callback is now reliably dropped rather than misrouted. Per-solve delivery needs solve identity threaded through the logging path, tracked in #1752. --- cpp/src/utilities/logger.cpp | 20 ++++++++++++-------- cpp/src/utilities/logger.hpp | 8 ++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/cpp/src/utilities/logger.cpp b/cpp/src/utilities/logger.cpp index 048f206f16..44ecc07906 100644 --- a/cpp/src/utilities/logger.cpp +++ b/cpp/src/utilities/logger.cpp @@ -210,12 +210,8 @@ static void* g_pending_callback_data = nullptr; static void user_log_bridge(int lvl, const char* msg) { - // Deliver only standard solver output — the lines a user would see on the - // console. Debug and trace are internal diagnostics: they are normally - // compiled out (CUOPT_LOG_ACTIVE_LEVEL defaults to INFO), but a build with a - // lower level, or CUOPT_LOG_LEVEL=DEBUG against such a build, would otherwise - // route them into user code. Filtering here makes the guarantee a property of - // the API rather than of the build configuration. + // Standard solver output only; debug/trace are internal diagnostics and must + // not reach user code even in a lower-level build. if (lvl < static_cast(rapids_logger::level_enum::info)) { return; } // g_active_log_callback is stable for the duration of any bridge call: @@ -244,8 +240,12 @@ init_logger_t::init_logger_t(std::string log_file, bool log_to_console) auto existing_guard = g_active_guard.lock(); if (existing_guard) { - // Reuse existing configuration, just hold a reference to keep it alive - guard_ = existing_guard; + // Reuse existing configuration, just hold a reference to keep it alive. + // Drop any pending callback: it cannot be installed on an already-configured + // guard, and a later guard would otherwise adopt it with stale user_data (#1752). + g_pending_callback = nullptr; + g_pending_callback_data = nullptr; + guard_ = existing_guard; return; } @@ -269,6 +269,10 @@ init_logger_t::init_logger_t(std::string log_file, bool log_to_console) g_active_log_callback = guard->callback_state.get(); cuopt::default_logger().sinks().push_back( std::make_shared(user_log_bridge)); + + // Consume the slot; the guard owns a copy now. + g_pending_callback = nullptr; + g_pending_callback_data = nullptr; } #if CUOPT_LOG_ACTIVE_LEVEL >= RAPIDS_LOGGER_LOG_LEVEL_INFO diff --git a/cpp/src/utilities/logger.hpp b/cpp/src/utilities/logger.hpp index 93d1b75b38..041725e97a 100644 --- a/cpp/src/utilities/logger.hpp +++ b/cpp/src/utilities/logger.hpp @@ -38,12 +38,8 @@ rapids_logger::logger& default_logger(); */ void reset_default_logger(); -// C-compatible log callback type: void callback(const char* msg, void* user_data) -// Matches cuOptLogCallback in cuopt_c.h — layout-compatible, no dependency on that header. -// -// Deliberately carries no severity. Exposing a level would let callers branch on -// it, which makes our internal log taxonomy a de-facto public API; the callback -// is for displaying or forwarding standard solver output only. +// C-compatible log callback type. Matches cuOptLogCallback in cuopt_c.h. +// Carries no severity by design: a level would become a de-facto public API. using log_callback_with_data_t = void (*)(const char* message, void* user_data); /** From 6562dda66bfe86244901121047f6fafdfc790348 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 20 Aug 2026 12:31:47 -0500 Subject: [PATCH 6/8] fix(c-api): make log callback registration per-thread Closes #1752. Registration went through a process-global slot that init_logger_t consumed when it built the logger guard. Two problems followed: a second solve registering between cuOptSolve and init_logger_t made the first capture the second's callback, and a solve reusing an already-active guard never got its callback installed at all. Both came from tying delivery to who registered last, and to which solve happened to build the guard. Registration is now thread-local, held by a scoped_log_callback_t for the duration of cuOptSolve, and the bridge sink is always installed -- it no-ops unless the logging thread has a registration. Delivery therefore depends only on who is logging. This removes the global slot, the captured-callback state on the guard, and set/clear_pending_log_callback. Log lines emitted on internal worker threads are not delivered, since those threads carry no registration. C_API_TEST asserts the callback fires at least once during a solve and that it stops after clearing, so standard solver output still arrives on the calling thread. --- cpp/src/pdlp/cuopt_c.cpp | 19 +++------ cpp/src/utilities/logger.cpp | 78 ++++++++++++------------------------ cpp/src/utilities/logger.hpp | 21 +++++++--- 3 files changed, 46 insertions(+), 72 deletions(-) diff --git a/cpp/src/pdlp/cuopt_c.cpp b/cpp/src/pdlp/cuopt_c.cpp index 7de24deb13..bef7e4d7fa 100644 --- a/cpp/src/pdlp/cuopt_c.cpp +++ b/cpp/src/pdlp/cuopt_c.cpp @@ -18,6 +18,8 @@ #include #include +#include + #include #include @@ -1159,21 +1161,12 @@ cuopt_int_t cuOptSolve(cuOptOptimizationProblem problem, if (settings == nullptr) { return CUOPT_INVALID_ARGUMENT; } if (solution_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } - // Install user log callback so init_logger_t inside the solver picks it up. - // The RAII guard clears it on scope exit (whether by return or exception). + // Register the callback for this thread for the duration of the solve. + // cuOptLogCallback and log_callback_with_data_t share the same signature. solver_settings_handle_t* handle = get_settings_handle(settings); - struct log_scope_guard_t { - bool has_callback; - ~log_scope_guard_t() - { - if (has_callback) { cuopt::clear_pending_log_callback(); } - } - } log_scope{false}; - + std::optional log_scope; if (handle->log_callback) { - // cuOptLogCallback and log_callback_with_data_t share the same signature. - cuopt::set_pending_log_callback(handle->log_callback, handle->log_callback_user_data); - log_scope.has_callback = true; + log_scope.emplace(handle->log_callback, handle->log_callback_user_data); } problem_and_stream_view_t* problem_and_stream_view = diff --git a/cpp/src/utilities/logger.cpp b/cpp/src/utilities/logger.cpp index 44ecc07906..0d6c6b9aef 100644 --- a/cpp/src/utilities/logger.cpp +++ b/cpp/src/utilities/logger.cpp @@ -173,40 +173,26 @@ void reset_default_logger() default_logger().flush_on(rapids_logger::level_enum::debug); } -// Forward declarations needed by logger_config_guard destructor. static std::mutex g_guard_mutex; -static const struct captured_log_callback_t* g_active_log_callback; - -// Captured (immutable) callback state owned by the active logger guard. -struct captured_log_callback_t { - log_callback_with_data_t callback; - void* user_data; -}; // Guard object whose destructor resets the logger. -// Owns the captured callback state to guarantee its lifetime. struct logger_config_guard { - std::unique_ptr callback_state; - ~logger_config_guard() - { - cuopt::reset_default_logger(); // removes the sink; blocks until in-flight log calls finish - std::lock_guard lock(g_guard_mutex); - g_active_log_callback = nullptr; // safe: the sink (and the bridge) are already gone - } + ~logger_config_guard() { cuopt::reset_default_logger(); } }; // Weak reference to detect if any init_logger_t instance is still alive static std::weak_ptr g_active_guard; -// g_active_log_callback: written only under g_guard_mutex (at guard create/destroy time). -// Read lock-free by user_log_bridge — safe because the bridge is only reachable -// while the sink is alive, and the sink is removed (in reset_default_logger) before -// this pointer is cleared. - -// Pending user log callback set by the C API before cuOptSolve. -// Consumed once (under g_guard_mutex) by init_logger_t to build the guard state. -static log_callback_with_data_t g_pending_callback = nullptr; -static void* g_pending_callback_data = nullptr; +// Registration is per-thread, not global: the sink is shared by every solve, so +// the callback has to be selected by who is logging rather than by who +// registered last (#1752). +namespace { +struct thread_log_callback_t { + log_callback_with_data_t callback = nullptr; + void* user_data = nullptr; +}; +thread_local thread_log_callback_t t_log_callback; +} // namespace static void user_log_bridge(int lvl, const char* msg) { @@ -214,24 +200,21 @@ static void user_log_bridge(int lvl, const char* msg) // not reach user code even in a lower-level build. if (lvl < static_cast(rapids_logger::level_enum::info)) { return; } - // g_active_log_callback is stable for the duration of any bridge call: - // it points into the guard's callback_state, which outlives the sink. - const captured_log_callback_t* state = g_active_log_callback; - if (state) { state->callback(msg, state->user_data); } + const auto& cb = t_log_callback; + if (cb.callback) { cb.callback(msg, cb.user_data); } } -void set_pending_log_callback(log_callback_with_data_t cb, void* user_data) +scoped_log_callback_t::scoped_log_callback_t(log_callback_with_data_t cb, void* user_data) + : prev_callback_(t_log_callback.callback), prev_user_data_(t_log_callback.user_data) { - std::lock_guard lock(g_guard_mutex); - g_pending_callback = cb; - g_pending_callback_data = user_data; + t_log_callback.callback = cb; + t_log_callback.user_data = user_data; } -void clear_pending_log_callback() +scoped_log_callback_t::~scoped_log_callback_t() { - std::lock_guard lock(g_guard_mutex); - g_pending_callback = nullptr; - g_pending_callback_data = nullptr; + t_log_callback.callback = prev_callback_; + t_log_callback.user_data = prev_user_data_; } init_logger_t::init_logger_t(std::string log_file, bool log_to_console) @@ -241,11 +224,7 @@ init_logger_t::init_logger_t(std::string log_file, bool log_to_console) auto existing_guard = g_active_guard.lock(); if (existing_guard) { // Reuse existing configuration, just hold a reference to keep it alive. - // Drop any pending callback: it cannot be installed on an already-configured - // guard, and a later guard would otherwise adopt it with stale user_data (#1752). - g_pending_callback = nullptr; - g_pending_callback_data = nullptr; - guard_ = existing_guard; + guard_ = existing_guard; return; } @@ -261,19 +240,12 @@ init_logger_t::init_logger_t(std::string log_file, bool log_to_console) std::make_shared(log_file, true)); cuopt::default_logger().flush_on(rapids_logger::level_enum::debug); } - // Capture pending callback into the guard so the bridge reads stable (immutable) state. auto guard = std::make_shared(); - if (g_pending_callback) { - guard->callback_state = std::make_unique( - captured_log_callback_t{g_pending_callback, g_pending_callback_data}); - g_active_log_callback = guard->callback_state.get(); - cuopt::default_logger().sinks().push_back( - std::make_shared(user_log_bridge)); - // Consume the slot; the guard owns a copy now. - g_pending_callback = nullptr; - g_pending_callback_data = nullptr; - } + // Always installed: the bridge no-ops unless the logging thread has a + // registration, so delivery no longer depends on which solve built the guard. + cuopt::default_logger().sinks().push_back( + std::make_shared(user_log_bridge)); #if CUOPT_LOG_ACTIVE_LEVEL >= RAPIDS_LOGGER_LOG_LEVEL_INFO cuopt::default_logger().set_pattern("%v"); diff --git a/cpp/src/utilities/logger.hpp b/cpp/src/utilities/logger.hpp index 041725e97a..9af626f40b 100644 --- a/cpp/src/utilities/logger.hpp +++ b/cpp/src/utilities/logger.hpp @@ -43,14 +43,23 @@ void reset_default_logger(); using log_callback_with_data_t = void (*)(const char* message, void* user_data); /** - * @brief Install a user log callback to be picked up by the next init_logger_t. + * @brief Registers a log callback for the calling thread, for its own lifetime. * - * Must be called before the init_logger_t that starts the targeted solve. - * Protected by the same mutex as init_logger_t so it is safe to call from - * any thread, but do not call from inside the callback itself. + * Registration is per-thread so concurrent solves cannot capture each other's + * callback. Log lines emitted on other threads are not delivered. */ -void set_pending_log_callback(log_callback_with_data_t cb, void* user_data); -void clear_pending_log_callback(); +class scoped_log_callback_t { + public: + scoped_log_callback_t(log_callback_with_data_t cb, void* user_data); + ~scoped_log_callback_t(); + + scoped_log_callback_t(const scoped_log_callback_t&) = delete; + scoped_log_callback_t& operator=(const scoped_log_callback_t&) = delete; + + private: + log_callback_with_data_t prev_callback_; + void* prev_user_data_; +}; // Ref-counted logger initializer class init_logger_t { From 30632cadc0dc5b7f4e36eb9ca90be1ce8106ef15 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Mon, 31 Aug 2026 13:59:22 -0500 Subject: [PATCH 7/8] fix(c-api): deliver the log callback for remote solves Answers @rg20's question on the PR: with CUOPT_REMOTE_HOST/PORT set, cuOptSolve dispatches to solve_lp_remote/solve_mip_remote, and the callback saw almost nothing. Two reasons. The streamed server log was written straight to std::cout and the log file, bypassing default_logger() and therefore the callback sink. And streaming was only requested when log_to_console or log_file was set, so a registered callback alone did not turn it on. Both paths now capture the calling thread's registration and forward each streamed line to it, and request streaming when a callback is present. The registration is captured at call time rather than read inside the lambda, because the gRPC client invokes log_callback from its own log_thread_, which carries no thread-local registration. Verified against a live cuopt_grpc_server: the same LP delivers 16 callback lines locally and 16 remotely, and the remote ones are the server's solver log (PSLP presolve, status). The header note is updated: the callback runs on the calling thread for a local solve and on the streaming thread for a remote one. --- .../cuopt/mathematical_optimization/cuopt_c.h | 5 +-- cpp/src/grpc/client/solve_remote.cpp | 36 +++++++++++-------- cpp/src/utilities/logger.cpp | 5 +++ cpp/src/utilities/logger.hpp | 13 +++++++ 4 files changed, 43 insertions(+), 16 deletions(-) diff --git a/cpp/include/cuopt/mathematical_optimization/cuopt_c.h b/cpp/include/cuopt/mathematical_optimization/cuopt_c.h index 5a34a82acc..61cd477f4e 100644 --- a/cpp/include/cuopt/mathematical_optimization/cuopt_c.h +++ b/cpp/include/cuopt/mathematical_optimization/cuopt_c.h @@ -839,8 +839,9 @@ cuopt_int_t cuOptGetFloatParameter(cuOptSolverSettings settings, * @param message Null-terminated log line without trailing newline. * @param user_data Opaque pointer passed to cuOptSetLogCallback. * - * @note The callback is invoked from the solver thread. Do not call back into - * cuOpt from inside the callback. + * @note Invoked from the calling thread for a local solve, and from an internal + * log-streaming thread when the solve runs on a remote server. Do not call back + * into cuOpt from inside the callback. * @warning Log message formatting is not part of the stable API and may change * between releases. The callback is intended for display purposes (GUI integration, * log forwarding, stdout capture) — do not parse message content for programmatic diff --git a/cpp/src/grpc/client/solve_remote.cpp b/cpp/src/grpc/client/solve_remote.cpp index eabea39e05..45d97f73ee 100644 --- a/cpp/src/grpc/client/solve_remote.cpp +++ b/cpp/src/grpc/client/solve_remote.cpp @@ -86,13 +86,18 @@ std::unique_ptr> solve_lp_remote( } bool want_console = settings.log_to_console; bool want_file = log_file_stream && log_file_stream->is_open(); - - if (want_console || want_file) { - config.stream_logs = true; - config.log_callback = [want_console, want_file, &log_file_stream](const std::string& line) { - if (want_console) { std::cout << line << std::endl; } - if (want_file) { *log_file_stream << line << std::endl; } - }; + // Captured here, not read inside the lambda: the streaming thread carries no + // registration of its own. + auto user_cb = cuopt::current_log_callback(); + + if (want_console || want_file || user_cb.callback) { + config.stream_logs = true; + config.log_callback = + [want_console, want_file, &log_file_stream, user_cb](const std::string& line) { + if (want_console) { std::cout << line << std::endl; } + if (want_file) { *log_file_stream << line << std::endl; } + if (user_cb.callback) { user_cb.callback(line.c_str(), user_cb.user_data); } + }; } // Create client and connect @@ -139,13 +144,16 @@ std::unique_ptr> solve_mip_remote( } bool want_console = settings.log_to_console; bool want_file = log_file_stream && log_file_stream->is_open(); - - if (want_console || want_file) { - config.stream_logs = true; - config.log_callback = [want_console, want_file, &log_file_stream](const std::string& line) { - if (want_console) { std::cout << line << std::endl; } - if (want_file) { *log_file_stream << line << std::endl; } - }; + auto user_cb = cuopt::current_log_callback(); + + if (want_console || want_file || user_cb.callback) { + config.stream_logs = true; + config.log_callback = + [want_console, want_file, &log_file_stream, user_cb](const std::string& line) { + if (want_console) { std::cout << line << std::endl; } + if (want_file) { *log_file_stream << line << std::endl; } + if (user_cb.callback) { user_cb.callback(line.c_str(), user_cb.user_data); } + }; } // Check if user has set incumbent callbacks diff --git a/cpp/src/utilities/logger.cpp b/cpp/src/utilities/logger.cpp index 0d6c6b9aef..81bc87878a 100644 --- a/cpp/src/utilities/logger.cpp +++ b/cpp/src/utilities/logger.cpp @@ -204,6 +204,11 @@ static void user_log_bridge(int lvl, const char* msg) if (cb.callback) { cb.callback(msg, cb.user_data); } } +log_callback_registration_t current_log_callback() +{ + return {t_log_callback.callback, t_log_callback.user_data}; +} + scoped_log_callback_t::scoped_log_callback_t(log_callback_with_data_t cb, void* user_data) : prev_callback_(t_log_callback.callback), prev_user_data_(t_log_callback.user_data) { diff --git a/cpp/src/utilities/logger.hpp b/cpp/src/utilities/logger.hpp index 9af626f40b..dcd211e949 100644 --- a/cpp/src/utilities/logger.hpp +++ b/cpp/src/utilities/logger.hpp @@ -42,6 +42,19 @@ void reset_default_logger(); // Carries no severity by design: a level would become a de-facto public API. using log_callback_with_data_t = void (*)(const char* message, void* user_data); +struct log_callback_registration_t { + log_callback_with_data_t callback = nullptr; + void* user_data = nullptr; +}; + +/** + * @brief The calling thread's current registration, if any. + * + * For forwarding log lines produced on a thread that carries no registration of + * its own, such as the remote-solve log-streaming thread. + */ +log_callback_registration_t current_log_callback(); + /** * @brief Registers a log callback for the calling thread, for its own lifetime. * From 31e6c1066e34f107fb2fd5f651bb7145c6990c94 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Mon, 31 Aug 2026 17:16:07 -0500 Subject: [PATCH 8/8] test(c-api): cover log callback delivery for remote solves Adds CpuOnlyWithServerTest.log_callback_remote, reusing the fixture that forks a real cuopt_grpc_server and points CUOPT_REMOTE_HOST/PORT at it. The assertion is that a line from the server's own solver log arrives, not merely that the callback fired: the client emits "Using remote GPU backend" locally, so a bare call-count check passes even when no server output is forwarded. Confirmed by reverting the forwarding in solve_remote.cpp, where the test fails with "delivered 1 lines but none from the server's solver log". --- .../c_api_tests/c_api_test.c | 53 +++++++++++++++++++ .../c_api_tests/c_api_tests.cpp | 7 +++ .../c_api_tests/c_api_tests.h | 1 + 3 files changed, 61 insertions(+) diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_test.c b/cpp/tests/linear_programming/c_api_tests/c_api_test.c index 76faef1785..134d7deca4 100644 --- a/cpp/tests/linear_programming/c_api_tests/c_api_test.c +++ b/cpp/tests/linear_programming/c_api_tests/c_api_test.c @@ -2847,6 +2847,59 @@ cuopt_int_t test_qcqp_solution_dual_methods() * This simulates a CPU host without GPU access. * Note: Environment variables must be set before calling this function. */ +/* Remote solve must deliver the *server's* log to the user callback, not just + the client-side lines. Requires CUOPT_REMOTE_HOST/PORT set by the caller. */ +typedef struct { + int calls; + int saw_solver_line; +} remote_log_ctx_t; + +static void remote_log_callback(const char* message, void* user_data) +{ + remote_log_ctx_t* ctx = (remote_log_ctx_t*)user_data; + ctx->calls++; + /* Emitted by the solver itself, so in remote mode it can only have come from + the server. Client-side lines alone would not contain it. */ + if (message && strstr(message, "Status:") != NULL) { ctx->saw_solver_line = 1; } +} + +cuopt_int_t test_log_callback_remote(const char* filename) +{ + cuOptOptimizationProblem problem = NULL; + cuOptSolverSettings settings = NULL; + cuOptSolution solution = NULL; + remote_log_ctx_t ctx = {0, 0}; + cuopt_int_t status; + + status = cuOptReadProblem(filename, &problem); + if (status != CUOPT_SUCCESS) goto DONE; + + status = cuOptCreateSolverSettings(&settings); + if (status != CUOPT_SUCCESS) goto DONE; + + status = cuOptSetLogCallback(settings, remote_log_callback, &ctx); + if (status != CUOPT_SUCCESS) goto DONE; + + status = cuOptSolve(problem, settings, &solution); + if (status != CUOPT_SUCCESS) goto DONE; + + if (ctx.calls < 1) { + printf("Expected remote solve to deliver log lines; got %d calls\n", ctx.calls); + status = CUOPT_INVALID_ARGUMENT; + goto DONE; + } + if (!ctx.saw_solver_line) { + printf("Remote solve delivered %d lines but none from the server's solver log\n", ctx.calls); + status = CUOPT_INVALID_ARGUMENT; + } + +DONE: + if (solution) cuOptDestroySolution(&solution); + if (settings) cuOptDestroySolverSettings(&settings); + if (problem) cuOptDestroyProblem(&problem); + return status; +} + cuopt_int_t test_cpu_only_execution(const char* filename) { cuOptOptimizationProblem problem = NULL; diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp b/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp index 2c1bb6c062..dadaf243fc 100644 --- a/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp +++ b/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp @@ -790,6 +790,13 @@ TEST_F(CpuOnlyWithServerTest, lp_solve) EXPECT_EQ(test_cpu_only_execution(lp_file.c_str()), CUOPT_SUCCESS); } +TEST_F(CpuOnlyWithServerTest, log_callback_remote) +{ + const std::string& rapidsDatasetRootDir = cuopt::test::get_rapids_dataset_root_dir(); + std::string lp_file = rapidsDatasetRootDir + "/linear_programming/afiro_original.mps"; + EXPECT_EQ(test_log_callback_remote(lp_file.c_str()), CUOPT_SUCCESS); +} + TEST_F(CpuOnlyWithServerTest, mip_solve) { const std::string& rapidsDatasetRootDir = cuopt::test::get_rapids_dataset_root_dir(); diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_tests.h b/cpp/tests/linear_programming/c_api_tests/c_api_tests.h index 8f989192a1..f24a9e78d3 100644 --- a/cpp/tests/linear_programming/c_api_tests/c_api_tests.h +++ b/cpp/tests/linear_programming/c_api_tests/c_api_tests.h @@ -82,6 +82,7 @@ cuopt_int_t test_pdlp_precision_mixed(const char* filename, /* CPU-only execution tests (require env vars CUDA_VISIBLE_DEVICES="" and CUOPT_REMOTE_HOST) */ cuopt_int_t test_cpu_only_execution(const char* filename); +cuopt_int_t test_log_callback_remote(const char* filename); cuopt_int_t test_cpu_only_mip_execution(const char* filename); /* CPU-host read/create C API (require CUDA_VISIBLE_DEVICES="", no remote, no solve) */