Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
10 changes: 9 additions & 1 deletion cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -292,10 +292,18 @@ FetchContent_MakeAvailable(papilo)

# PSLP - Lightweight C presolver for linear programs
# https://github.com/dance858/PSLP
#
# Pinned past v0.0.11 to a commit rather than a tag: it carries the fix for
# https://github.com/dance858/PSLP/pull/55 (run_presolver() printed its infeasible/unbounded
# message straight to stdout even with verbose = false, unlike every other console message in
# that function -- cuOpt sets verbose = false specifically to keep PSLP silent, and the stray
# write corrupted Maven Surefire's forked-JVM protocol in the Java bindings, which also uses
# stdout as its own channel). Move this to a released tag once dance858/PSLP cuts one that
# includes it.
FetchContent_Declare(
pslp
GIT_REPOSITORY "https://github.com/dance858/PSLP.git"
GIT_TAG "v0.0.11"
GIT_TAG "12d37dd9ab5ee848b3ec5da17f4cf8e805d58cd6"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should n't this be a separate PR?

GIT_PROGRESS TRUE
EXCLUDE_FROM_ALL
SYSTEM
Expand Down
30 changes: 27 additions & 3 deletions cpp/src/utilities/logger.cpp
Original file line number Diff line number Diff line change
@@ -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 */
Expand Down Expand Up @@ -55,6 +55,25 @@ log_buffer& global_log_buffer()
return buffer;
}

// Overrides the sink used when log_to_console is true. Null (the default) keeps writing to
// std::cout; set by language bindings whose host runtime cannot safely receive writes to the
// native stdout stream -- for example Java, where a raw write there bypasses System.out and can
// corrupt tools that intercept it, such as Maven Surefire's forked-process protocol.
static std::mutex g_console_callback_mutex;
static log_console_callback_t g_console_callback = nullptr;

void set_console_log_callback(log_console_callback_t callback)
{
std::lock_guard<std::mutex> lock(g_console_callback_mutex);
g_console_callback = callback;
}

static log_console_callback_t console_log_callback()
{
std::lock_guard<std::mutex> lock(g_console_callback_mutex);
return g_console_callback;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Callback function for the buffer sink
static void buffer_log_callback(int lvl, const char* msg)
{
Expand Down Expand Up @@ -161,8 +180,13 @@ init_logger_t::init_logger_t(std::string log_file, bool log_to_console)

// re-initialize sinks
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()) {
cuopt::default_logger().sinks().push_back(
Expand Down
13 changes: 13 additions & 0 deletions cpp/src/utilities/logger.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ rapids_logger::logger& default_logger();
*/
void reset_default_logger();

using log_console_callback_t = void (*)(int level, const char* message);

/**
* @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 -- see the
* definition site in logger.cpp for why that matters.
*
* @param callback The callback to invoke for each logged line, or nullptr to restore std::cout.
*/
void set_console_log_callback(log_console_callback_t callback);

// Ref-counted logger initializer
class init_logger_t {
// Using shared_ptr for ref-counting
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 @@ -5,3 +5,6 @@

# 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)
79 changes: 79 additions & 0 deletions cpp/tests/utilities/test_console_log_callback.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/* 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 and its call site in logger.cpp for the corrupted-Surefire-
// channel motivation.
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::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