Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cpp/src/math_optimization/logger_entry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,9 @@ std::shared_ptr<void> 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
46 changes: 41 additions & 5 deletions cpp/src/utilities/logger.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::mutex> lock(g_console_callback_mutex);
g_console_callback = callback;
}

inline log_console_callback_t console_log_callback()
{
std::lock_guard<std::mutex> lock(g_console_callback_mutex);
return g_console_callback;
}

// Buffer to store log messages
class log_buffer {
public:
Expand Down Expand Up @@ -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<rapids_logger::ostream_sink_mt>(std::cout));
if (auto callback = console_log_callback(); callback != nullptr) {
cuopt::default_logger().sinks().push_back(
std::make_shared<rapids_logger::callback_sink_mt>(callback));
} else {
cuopt::default_logger().sinks().push_back(
std::make_shared<rapids_logger::ostream_sink_mt>(std::cout));
}
}
if (!log_file.empty()) {
if (truncate) { std::ofstream(log_file, std::ios::trunc); }
Expand Down Expand Up @@ -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<void> 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 {
Expand Down
3 changes: 3 additions & 0 deletions cpp/tests/utilities/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
78 changes: 78 additions & 0 deletions cpp/tests/utilities/test_console_log_callback.cpp
Original file line number Diff line number Diff line change
@@ -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 <utilities/logger.hpp>

#include <gtest/gtest.h>

#include <string>
#include <vector>

namespace {

std::vector<std::string>& captured_lines()
{
static std::vector<std::string> 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());
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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);
}
}
54 changes: 54 additions & 0 deletions java/cuopt/src/main/native/cuopt_jni.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#include <cuopt/mathematical_optimization/io/parser.hpp>
#include <cuopt/mathematical_optimization/optimization_problem_utils.hpp>
#include <pdlp/cuopt_c_internal.hpp>
#include <utilities/logger.hpp>

#include <jni.h>

Expand Down Expand Up @@ -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(); }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// 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<jclass>(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*)
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading