diff --git a/cpp/src/math_optimization/logger_entry.cpp b/cpp/src/math_optimization/logger_entry.cpp index 78f2c0d208..3dd082c114 100644 --- a/cpp/src/math_optimization/logger_entry.cpp +++ b/cpp/src/math_optimization/logger_entry.cpp @@ -16,4 +16,9 @@ std::shared_ptr configure_logging(const std::string& log_file, return cuopt::make_logger_config(log_file, log_to_console, truncate); } +void set_console_log_callback(cuopt::log_console_callback_t callback) +{ + cuopt::set_console_log_callback(callback); +} + } // namespace cuopt::mathematical_optimization diff --git a/cpp/src/utilities/logger.hpp b/cpp/src/utilities/logger.hpp index 8eaaa1bec0..b786b817b8 100644 --- a/cpp/src/utilities/logger.hpp +++ b/cpp/src/utilities/logger.hpp @@ -45,6 +45,34 @@ struct buffered_entry { std::string msg; }; +using log_console_callback_t = void (*)(int level, const char* message); + +inline std::mutex g_console_callback_mutex; +inline log_console_callback_t g_console_callback = nullptr; + +/** + * @brief Overrides the sink used for console logging (settings.log_to_console == true). + * + * Passing nullptr (the default) restores writing to std::cout. Intended for language bindings + * whose host runtime cannot safely receive a raw write to the native stdout stream. + * + * Per-image state, like the logger itself -- reach a specific component library's copy through + * its exported `set_console_log_callback`, the same way `configure_logging` reaches its logger. + * + * @param callback The callback to invoke for each logged line, or nullptr to restore std::cout. + */ +inline void set_console_log_callback(log_console_callback_t callback) +{ + std::lock_guard lock(g_console_callback_mutex); + g_console_callback = callback; +} + +inline log_console_callback_t console_log_callback() +{ + std::lock_guard lock(g_console_callback_mutex); + return g_console_callback; +} + // Buffer to store log messages class log_buffer { public: @@ -160,8 +188,13 @@ inline void apply_logger_config(const std::string& log_file, bool log_to_console cuopt::default_logger().sinks().clear(); if (log_to_console) { - cuopt::default_logger().sinks().push_back( - std::make_shared(std::cout)); + if (auto callback = console_log_callback(); callback != nullptr) { + cuopt::default_logger().sinks().push_back( + std::make_shared(callback)); + } else { + cuopt::default_logger().sinks().push_back( + std::make_shared(std::cout)); + } } if (!log_file.empty()) { if (truncate) { std::ofstream(log_file, std::ios::trunc); } @@ -236,13 +269,16 @@ inline init_logger_t::init_logger_t(std::string log_file, bool log_to_console, b } // namespace cuopt -// Configures cuopt_mathopt's logger. The only logging symbol that crosses a library boundary, -// and it exists for one caller: an executable that writes the same log file as the solver and -// must configure it before the solver's own initializer would truncate it. +// Configures cuopt_mathopt's logger. The only logging symbols that cross a library boundary. +// configure_logging exists for one caller: an executable that writes the same log file as the +// solver and must configure it before the solver's own initializer would truncate it. +// set_console_log_callback exists for another: a language binding, such as Java, whose host +// runtime cannot safely receive a raw write to the native stdout stream. namespace cuopt::mathematical_optimization { CUOPT_EXPORT std::shared_ptr configure_logging(const std::string& log_file, bool log_to_console, bool truncate); +CUOPT_EXPORT void set_console_log_callback(log_console_callback_t callback); } // namespace cuopt::mathematical_optimization namespace cuopt::detail { diff --git a/cpp/tests/utilities/CMakeLists.txt b/cpp/tests/utilities/CMakeLists.txt index 70979d7172..cbcd70adae 100644 --- a/cpp/tests/utilities/CMakeLists.txt +++ b/cpp/tests/utilities/CMakeLists.txt @@ -6,5 +6,8 @@ # Add CLI end-to-end test ConfigureTest(CLI_TEST test_cli.cpp LABELS numopt) +# Add console-log-callback unit tests +ConfigureTest(CONSOLE_LOG_CALLBACK_TEST test_console_log_callback.cpp LABELS numopt) + # Logger boundary: per-library instances, shared log file, truncate semantics ConfigureTest(LOGGER_TEST test_logger.cpp LABELS numopt) diff --git a/cpp/tests/utilities/test_console_log_callback.cpp b/cpp/tests/utilities/test_console_log_callback.cpp new file mode 100644 index 0000000000..950cceff7d --- /dev/null +++ b/cpp/tests/utilities/test_console_log_callback.cpp @@ -0,0 +1,78 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include + +#include + +#include +#include + +namespace { + +std::vector& captured_lines() +{ + static std::vector lines; + return lines; +} + +void capturing_callback(int /* level */, const char* message) +{ + captured_lines().push_back(message); +} + +} // namespace + +// Covers the console-sink override added for language bindings (Java in particular) whose host +// runtime cannot safely receive a raw write to the native stdout stream -- see +// cuopt::set_console_log_callback in logger.hpp. +class console_log_callback_test : public ::testing::Test { + protected: + void TearDown() override + { + // Every test must leave the override cleared, or a later test (or a later suite entirely, + // since the callback is process-global) would silently pick up a stale callback. + cuopt::set_console_log_callback(nullptr); + captured_lines().clear(); + } +}; + +TEST_F(console_log_callback_test, registered_callback_receives_console_output) +{ + cuopt::set_console_log_callback(&capturing_callback); + { + cuopt::init_logger_t guard("", /* log_to_console = */ true); + CUOPT_LOG_INFO("hello from console_log_callback_test"); + } + + ASSERT_FALSE(captured_lines().empty()); + EXPECT_NE(captured_lines().back().find("hello from console_log_callback_test"), + std::string::npos); +} + +TEST_F(console_log_callback_test, nullptr_callback_falls_back_to_stdout_without_crashing) +{ + cuopt::set_console_log_callback(nullptr); + + EXPECT_NO_THROW({ + cuopt::init_logger_t guard("", /* log_to_console = */ true); + CUOPT_LOG_INFO("this goes to std::cout, not a callback"); + }); + // No callback was registered, so nothing should have been captured through it. + EXPECT_TRUE(captured_lines().empty()); +} + +TEST_F(console_log_callback_test, log_to_console_false_suppresses_both_sinks) +{ + cuopt::set_console_log_callback(&capturing_callback); + { + cuopt::init_logger_t guard("", /* log_to_console = */ false); + CUOPT_LOG_INFO("should not reach either sink"); + } + + EXPECT_TRUE(captured_lines().empty()); +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLogSink.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLogSink.java new file mode 100644 index 0000000000..1656aaea6f --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLogSink.java @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicaloptimization; + +/** + * Receives cuOpt's console log lines from native code and writes them through {@link + * System#out}, rather than the native library writing to the process's stdout stream directly. + * + *

A direct native write bypasses {@code System.out}, so it is invisible to anything that + * intercepts or redirects it -- {@link System#setOut}, a logging framework bridge, or Maven + * Surefire, which uses the forked JVM's stdout as its own communication channel and can + * misinterpret an unexpected raw write on it as the forked process having crashed. + * + *

Called from {@code cuopt_jni.cpp}; not part of the public API. + */ +final class NativeLogSink { + private NativeLogSink() {} + + static void onLogLine(String message) { + System.out.print(message); + } +} diff --git a/java/cuopt/src/main/native/cuopt_jni.cpp b/java/cuopt/src/main/native/cuopt_jni.cpp index 2f7cc27ef1..db6eec2124 100644 --- a/java/cuopt/src/main/native/cuopt_jni.cpp +++ b/java/cuopt/src/main/native/cuopt_jni.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include @@ -378,6 +379,58 @@ void mip_set_solution_callback(cuopt_float_t* solution, if (detach) { g_jvm->DetachCurrentThread(); } } +jclass g_log_sink_class = nullptr; +jmethodID g_log_sink_method = nullptr; +std::once_flag g_log_sink_once; + +// cuopt::log_console_callback_t: forwards a console log line to NativeLogSink.onLogLine, so it +// is written through System.out instead of directly to the native stdout stream. See +// register_console_log_sink for why that distinction matters. +void console_log_callback(int /* level */, const char* message) +{ + if (g_log_sink_class == nullptr || g_log_sink_method == nullptr) { return; } + + bool detach = false; + JNIEnv* env = get_callback_env(detach); + if (env == nullptr) { return; } + + jstring line = env->NewStringUTF(message); + if (line != nullptr) { + env->CallStaticVoidMethod(g_log_sink_class, g_log_sink_method, line); + env->DeleteLocalRef(line); + } + // A logging call is not the place to raise a Java exception; drop it rather than leave it + // pending for whatever JNI call happens to run next on this thread. Covers both + // CallStaticVoidMethod above and NewStringUTF's OutOfMemoryError when line is null. + if (env->ExceptionCheck() == JNI_TRUE) { env->ExceptionClear(); } + + if (detach) { g_jvm->DetachCurrentThread(); } +} + +// Registers console_log_callback with the native logger, once. Done lazily on first use (rather +// than in JNI_OnLoad) because FindClass needs the caller's classloader, which JNI_OnLoad does not +// reliably have. +void register_console_log_sink(JNIEnv* env) +{ + std::call_once(g_log_sink_once, [env]() { + jclass local_cls = env->FindClass("com/nvidia/cuopt/mathematicaloptimization/NativeLogSink"); + if (local_cls == nullptr) { + env->ExceptionClear(); + return; + } + jmethodID method = env->GetStaticMethodID(local_cls, "onLogLine", "(Ljava/lang/String;)V"); + if (method == nullptr) { + env->ExceptionClear(); + env->DeleteLocalRef(local_cls); + return; + } + g_log_sink_class = static_cast(env->NewGlobalRef(local_cls)); + g_log_sink_method = method; + env->DeleteLocalRef(local_cls); + cuopt::mathematical_optimization::set_console_log_callback(&console_log_callback); + }); +} + } // namespace extern "C" jint JNI_OnLoad(JavaVM* vm, void*) @@ -421,6 +474,7 @@ Java_com_nvidia_cuopt_mathematicaloptimization_NativeCuOpt_readProblemWithFormat extern "C" JNIEXPORT jlong JNICALL Java_com_nvidia_cuopt_mathematicaloptimization_NativeCuOpt_createSolverSettings(JNIEnv* env, jclass) { + register_console_log_sink(env); cuOptSolverSettings settings = nullptr; if (!check_status(env, cuOptCreateSolverSettings(&settings), "cuOptCreateSolverSettings")) { return 0; diff --git a/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicaloptimization/NativeLogSinkTest.java b/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicaloptimization/NativeLogSinkTest.java new file mode 100644 index 0000000000..89ad7c3780 --- /dev/null +++ b/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicaloptimization/NativeLogSinkTest.java @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicaloptimization; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Verifies that {@link NativeLogSink#onLogLine} writes through {@link System#out} rather than + * bypassing it -- the whole point of routing native console log lines through this class instead + * of a direct native write. See {@link NativeLogSink} for why a direct write is unsafe here. + */ +final class NativeLogSinkTest { + + private final PrintStream originalOut = System.out; + + @AfterEach + void restoreSystemOut() { + System.setOut(originalOut); + } + + @Test + void onLogLineWritesThroughSystemOut() { + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + System.setOut(new PrintStream(captured, true, StandardCharsets.UTF_8)); + + NativeLogSink.onLogLine("Solving a problem with 1 constraints, 1 variables\n"); + + assertEquals( + "Solving a problem with 1 constraints, 1 variables\n", + captured.toString(StandardCharsets.UTF_8)); + } + + @Test + void onLogLineReflectsSystemSetOutRedirection() { + ByteArrayOutputStream first = new ByteArrayOutputStream(); + System.setOut(new PrintStream(first, true, StandardCharsets.UTF_8)); + NativeLogSink.onLogLine("first\n"); + + ByteArrayOutputStream second = new ByteArrayOutputStream(); + System.setOut(new PrintStream(second, true, StandardCharsets.UTF_8)); + NativeLogSink.onLogLine("second\n"); + + assertEquals("first\n", first.toString(StandardCharsets.UTF_8)); + assertEquals("second\n", second.toString(StandardCharsets.UTF_8)); + } +}