From 2b87330d6624cc8ffb2e1e1ad3f8f3f14d47782e Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 28 Aug 2026 11:10:10 -0500 Subject: [PATCH 1/5] Route Java console logging through System.out instead of native stdout cuOpt's C++ logger writes console output directly to std::cout when log_to_console is enabled (the common case), bypassing Java's System.out entirely. In the Java bindings, that raw write to the process's native stdout stream corrupts Maven Surefire's forked-JVM IPC protocol, which also uses stdout as its channel -- intermittently turning a passing test run into a reported "VM crash" depending on whether a log line happens to interleave with a protocol frame. Reproduced locally: NativeIntegrationTest's PDLP/MIP solves reliably trigger Surefire's "Corrupted channel by directly writing to native stream" warning, occasionally escalating to a hard failure. Add a console-sink override hook to the shared logger (set_console_log_callback), used only when a caller registers one; behavior for the Python, C, CLI, and server bindings is unchanged. The Java JNI layer registers a callback that forwards each log line to a new NativeLogSink.onLogLine, which writes it through System.out -- letting Surefire (and any other System.out interceptor, e.g. a redirect or logging bridge) see it like ordinary Java output instead of a raw native write. Known residual gap: PSLP, a vendored third-party presolver linked into libcuopt, prints its own status lines directly via printf and does not go through cuopt's logger, so it is not covered by this callback. It surfaces far less often than the fix's scope (only a short presolve status line, versus the solver's console banner and progress log on every solve), but is a separate, harder fix (patching or forking the vendored library) tracked separately. Co-Authored-By: Claude Sonnet 5 --- cpp/src/utilities/logger.cpp | 28 +++++++++- cpp/src/utilities/logger.hpp | 13 +++++ .../NativeLogSink.java | 24 +++++++++ java/cuopt/src/main/native/cuopt_jni.cpp | 53 +++++++++++++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLogSink.java diff --git a/cpp/src/utilities/logger.cpp b/cpp/src/utilities/logger.cpp index 217f9c64cb..91c1b28724 100644 --- a/cpp/src/utilities/logger.cpp +++ b/cpp/src/utilities/logger.cpp @@ -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 lock(g_console_callback_mutex); + g_console_callback = callback; +} + +static log_console_callback_t console_log_callback() +{ + std::lock_guard lock(g_console_callback_mutex); + return g_console_callback; +} + // Callback function for the buffer sink static void buffer_log_callback(int lvl, const char* msg) { @@ -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(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()) { cuopt::default_logger().sinks().push_back( diff --git a/cpp/src/utilities/logger.hpp b/cpp/src/utilities/logger.hpp index 2f9053b05f..cc0f79175e 100644 --- a/cpp/src/utilities/logger.hpp +++ b/cpp/src/utilities/logger.hpp @@ -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 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..540698fa87 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,57 @@ 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); + // 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. + if (env->ExceptionCheck() == JNI_TRUE) { env->ExceptionClear(); } + env->DeleteLocalRef(line); + } + + 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::set_console_log_callback(&console_log_callback); + }); +} + } // namespace extern "C" jint JNI_OnLoad(JavaVM* vm, void*) @@ -421,6 +473,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; From 99a33fc5d74cef7ad95a38154c4d4dc50d945148 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 28 Aug 2026 11:21:24 -0500 Subject: [PATCH 2/5] Fix pre-commit findings: copyright year and clang-format alignment Co-Authored-By: Claude Sonnet 5 --- cpp/src/utilities/logger.cpp | 2 +- java/cuopt/src/main/native/cuopt_jni.cpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cpp/src/utilities/logger.cpp b/cpp/src/utilities/logger.cpp index 91c1b28724..7b2170db34 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 */ diff --git a/java/cuopt/src/main/native/cuopt_jni.cpp b/java/cuopt/src/main/native/cuopt_jni.cpp index 540698fa87..439ac63cd1 100644 --- a/java/cuopt/src/main/native/cuopt_jni.cpp +++ b/java/cuopt/src/main/native/cuopt_jni.cpp @@ -379,8 +379,8 @@ 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; +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 From 297305962faa6e0e1b2669772e2005cb7aba40e3 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 28 Aug 2026 16:48:06 -0500 Subject: [PATCH 3/5] Patch vendored PSLP to respect verbose=false for its infeasible message Root-caused the residual Corrupted channel failures still hitting java-static-test after the NativeLogSink fix: PSLP v0.0.11's run_presolver() gates every other console message behind stgs->verbose (print_start_message, print_end_message), but calls print_infeas_or_unbnd_message() unconditionally when it detects the problem is infeasible or unbounded. cuOpt already sets verbose = false when calling PSLP (third_party_presolve.cpp), specifically to keep it silent, so this one line slips through despite that and writes straight to the process's native stdout -- bypassing System.out exactly like the raw write NativeLogSink was built to intercept, and corrupting Surefire's forked-JVM protocol the same way. The infeasible/unbounded status itself is unaffected: it already flows back to the caller through run_presolver()'s typed return value, not by parsing this printed text, so cuOpt's own (properly routed) status reporting is unchanged. Filed and fixed upstream: https://github.com/dance858/PSLP/pull/55. Until a release containing it is available, patch the vendored v0.0.11 source at fetch time via a new PATCH_COMMAND on PSLP's FetchContent_Declare. Verified locally: rebuilt libcuopt_static + the JNI layer with the patch applied (confirmed via the fetched source) and ran the full Java suite, including ProblemIntegrationTest's infeasible-solve case which is what triggers this code path, 50 times in a loop. Every run passed with zero "Corrupted channel" occurrences (previously this reproduced on the very first attempt). Co-Authored-By: Claude Sonnet 5 --- cpp/CMakeLists.txt | 10 ++++++++++ .../respect_verbose_for_infeasible_message.patch | 16 ++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 cpp/cmake/patches/pslp/respect_verbose_for_infeasible_message.patch diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index b375cc4c56..24f82c79df 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -292,11 +292,21 @@ FetchContent_MakeAvailable(papilo) # PSLP - Lightweight C presolver for linear programs # https://github.com/dance858/PSLP +# +# v0.0.11 ignores its own verbose=false setting for one message: run_presolver() prints +# "PSLP declares problem as infeasible[.| or unbounded.]" unconditionally, unlike every other +# console message in that function, which are all gated on verbose. cuOpt sets verbose = false +# (see third_party_presolve.cpp) precisely so PSLP stays silent, so this writes unexpectedly +# straight to the process's native stdout -- observed corrupting Maven Surefire's forked-JVM +# protocol in the Java bindings, which also uses stdout as its own channel. Patched upstream at +# https://github.com/dance858/PSLP/pull/55; drop this patch once a release containing it is +# available and this GIT_TAG is bumped past it. FetchContent_Declare( pslp GIT_REPOSITORY "https://github.com/dance858/PSLP.git" GIT_TAG "v0.0.11" GIT_PROGRESS TRUE + PATCH_COMMAND sh -c "git apply --check '${CMAKE_CURRENT_SOURCE_DIR}/cmake/patches/pslp/respect_verbose_for_infeasible_message.patch' 2>/dev/null && git apply '${CMAKE_CURRENT_SOURCE_DIR}/cmake/patches/pslp/respect_verbose_for_infeasible_message.patch'; true" EXCLUDE_FROM_ALL SYSTEM ) diff --git a/cpp/cmake/patches/pslp/respect_verbose_for_infeasible_message.patch b/cpp/cmake/patches/pslp/respect_verbose_for_infeasible_message.patch new file mode 100644 index 0000000000..19f93171f8 --- /dev/null +++ b/cpp/cmake/patches/pslp/respect_verbose_for_infeasible_message.patch @@ -0,0 +1,16 @@ +diff --git a/src/core/Presolver.c b/src/core/Presolver.c +index c0bdc9e..426008e 100644 +--- a/src/core/Presolver.c ++++ b/src/core/Presolver.c +@@ -720,7 +720,10 @@ PresolveStatus run_presolver(Presolver *presolver) + if (status != UNCHANGED) + { + // problem detected to be infeasible or unbounded +- print_infeas_or_unbnd_message(status); ++ if (stgs->verbose) ++ { ++ print_infeas_or_unbnd_message(status); ++ } + return status; + } + From aeeb1a1eb6332a80e0c6577c2bf1143583eb9fee Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 28 Aug 2026 16:56:07 -0500 Subject: [PATCH 4/5] Strip trailing whitespace from the PSLP patch file (pre-commit) Co-Authored-By: Claude Sonnet 5 --- .../patches/pslp/respect_verbose_for_infeasible_message.patch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpp/cmake/patches/pslp/respect_verbose_for_infeasible_message.patch b/cpp/cmake/patches/pslp/respect_verbose_for_infeasible_message.patch index 19f93171f8..a0c344f972 100644 --- a/cpp/cmake/patches/pslp/respect_verbose_for_infeasible_message.patch +++ b/cpp/cmake/patches/pslp/respect_verbose_for_infeasible_message.patch @@ -13,4 +13,4 @@ index c0bdc9e..426008e 100644 + } return status; } - + From dba053964b621435cb224b635004edc731836842 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 28 Aug 2026 16:58:50 -0500 Subject: [PATCH 5/5] Drop the trailing blank context line from the PSLP patch (pre-commit) Co-Authored-By: Claude Sonnet 5 --- .../patches/pslp/respect_verbose_for_infeasible_message.patch | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cpp/cmake/patches/pslp/respect_verbose_for_infeasible_message.patch b/cpp/cmake/patches/pslp/respect_verbose_for_infeasible_message.patch index a0c344f972..784ab23370 100644 --- a/cpp/cmake/patches/pslp/respect_verbose_for_infeasible_message.patch +++ b/cpp/cmake/patches/pslp/respect_verbose_for_infeasible_message.patch @@ -2,7 +2,7 @@ diff --git a/src/core/Presolver.c b/src/core/Presolver.c index c0bdc9e..426008e 100644 --- a/src/core/Presolver.c +++ b/src/core/Presolver.c -@@ -720,7 +720,10 @@ PresolveStatus run_presolver(Presolver *presolver) +@@ -720,6 +720,9 @@ PresolveStatus run_presolver(Presolver *presolver) if (status != UNCHANGED) { // problem detected to be infeasible or unbounded @@ -13,4 +13,3 @@ index c0bdc9e..426008e 100644 + } return status; } -