From c156526a8b9134942b3aeb899269af074c427a19 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 27 Aug 2026 10:59:10 -0500 Subject: [PATCH 01/31] Make cuopt_static buildable for packaging, and add a scoped static build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cuopt_static existed only inside the BUILD_TESTS block, because the internal tests were its only consumer. Embedding cuOpt into a single self-contained shared object needs the same archive, so it is now gated on BUILD_TESTS or a new CUOPT_BUILD_STATIC_LIB option, with the tests block left to add_subdirectory alone. build_static_libcuopt.sh builds that archive scoped to what the Java bindings expose — no routing, no gRPC — and reports its size. The shared libcuopt is 554 MB against 29 DT_NEEDED entries, and Maven Central caps an upload bundle at 1 GB, so the measurement decides whether self-contained classifier JARs are feasible at all. Contributes to #1817. Signed-off-by: Ramakrishna Prabhu --- cpp/CMakeLists.txt | 14 +++++-- java/cuopt/ci/build_static_libcuopt.sh | 58 ++++++++++++++++++++++++++ java/cuopt/ci/java_classifier.sh | 41 ++++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) create mode 100755 java/cuopt/ci/build_static_libcuopt.sh create mode 100755 java/cuopt/ci/java_classifier.sh diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index b375cc4c56..b74fce4ef4 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -52,6 +52,7 @@ option(BUILD_LP_ONLY "Build only linear programming components, exclude routing option(SKIP_C_PYTHON_ADAPTERS "Skip building C and Python adapter files (cython_solve.cu and cuopt_c.cpp)" OFF) option(SKIP_ROUTING_BUILD "Skip building routing components" OFF) option(SKIP_GRPC_BUILD "Skip building gRPC and protobuf components" OFF) +option(CUOPT_BUILD_STATIC_LIB "Build libcuopt_static.a for embedding into a self-contained consumer" OFF) option(WRITE_FATBIN "Enable fatbin writing" ON) option(HOST_LINEINFO "Build with debug line information for host code" OFF) @@ -774,9 +775,10 @@ target_link_libraries(cuopt_objs ) # ################################################################################################## -# - generate tests -------------------------------------------------------------------------------- -if (BUILD_TESTS) - include(CTest) +# - static library -------------------------------------------------------------------------------- +# Built for the internal tests, and for consumers that embed cuOpt into a single self-contained +# shared object rather than linking the shared libcuopt (see the Java classifier JARs). +if (BUILD_TESTS OR CUOPT_BUILD_STATIC_LIB) add_library(cuopt_static STATIC $) target_link_libraries(cuopt_static PUBLIC @@ -817,6 +819,12 @@ if (BUILD_TESTS) if (TARGET KaMinPar) add_dependencies(cuopt_static KaMinPar) endif () +endif (BUILD_TESTS OR CUOPT_BUILD_STATIC_LIB) + +# ################################################################################################## +# - generate tests -------------------------------------------------------------------------------- +if (BUILD_TESTS) + include(CTest) add_subdirectory(tests) endif (BUILD_TESTS) diff --git a/java/cuopt/ci/build_static_libcuopt.sh b/java/cuopt/ci/build_static_libcuopt.sh new file mode 100755 index 0000000000..0888ecd525 --- /dev/null +++ b/java/cuopt/ci/build_static_libcuopt.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Builds libcuopt_static.a scoped to what the Java bindings actually expose, and reports its +# size. See #1817. +# +# The Java API covers LP, MIP and QP only, so routing, the gRPC server and NCCL's distributed +# PDLP path are all excluded. That matters because the shared libcuopt is 554 MB against 29 +# DT_NEEDED entries, and Maven Central caps an upload bundle at 1 GB — a self-contained JAR is +# only viable if the embedded library is scoped first. +# +# This script does not produce a JAR. It exists to measure whether one is feasible. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +BUILD_DIR="${BUILD_DIR:-${REPO_ROOT}/cpp/build-static}" +PARALLEL_LEVEL="${PARALLEL_LEVEL:-$(nproc)}" +CUDA_ARCHS="${CUOPT_CMAKE_CUDA_ARCHITECTURES:-RAPIDS}" + +# Routing and gRPC are excluded here rather than in a Java-specific fork of the build, because +# cpp/CMakeLists.txt already offers the switches. +cmake_args=( + -S "${REPO_ROOT}/cpp" + -B "${BUILD_DIR}" + -GNinja + -DCMAKE_BUILD_TYPE=Release + -DCUOPT_BUILD_STATIC_LIB=ON + -DBUILD_TESTS=OFF + -DSKIP_ROUTING_BUILD=ON + -DSKIP_GRPC_BUILD=ON + -DCMAKE_CUDA_ARCHITECTURES="${CUDA_ARCHS}" +) + +echo "Configuring scoped static build in ${BUILD_DIR}" +cmake "${cmake_args[@]}" + +echo "Building cuopt_static with ${PARALLEL_LEVEL} jobs" +cmake --build "${BUILD_DIR}" --target cuopt_static --parallel "${PARALLEL_LEVEL}" + +archive="$(find "${BUILD_DIR}" -name 'libcuopt_static.a' -print -quit)" +if [[ -z "${archive}" ]]; then + echo "cuopt_static built but libcuopt_static.a was not found under ${BUILD_DIR}" >&2 + exit 1 +fi + +# The archive is an upper bound, not the shipped size: linking it into a shared object keeps +# only the objects that are actually referenced. +size_mb=$(( $(stat -c%s "${archive}") / 1048576 )) +echo +echo " archive : ${archive}" +echo " size : ${size_mb} MB (unlinked upper bound)" +echo +echo "Link this into libcuopt_jni.so to get the figure that decides whether a" +echo "self-contained classifier JAR fits inside the 1 GB Maven Central bundle limit." diff --git a/java/cuopt/ci/java_classifier.sh b/java/cuopt/ci/java_classifier.sh new file mode 100755 index 0000000000..9197c266fa --- /dev/null +++ b/java/cuopt/ci/java_classifier.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Derives the Maven classifier for a self-contained cuOpt Java JAR. +# +# A classifier names the one combination of CUDA major version and CPU architecture that the +# JAR's embedded native library will run on. x86_64 carries no architecture suffix, matching +# the scheme cuDF publishes under (cuda12, cuda12-arm64, cuda13, cuda13-arm64). + +# cuopt_java_classifier [arch] +# cuda-version full or major-only, e.g. "13.0.3" or "13" +# arch defaults to the host's uname -m +cuopt_java_classifier() { + local cuda_version="${1:?missing cuda version}" + local arch="${2:-$(uname -m)}" + local cuda_major="${cuda_version%%.*}" + + case "${arch}" in + x86_64 | amd64) printf 'cuda%s\n' "${cuda_major}" ;; + aarch64 | arm64) printf 'cuda%s-arm64\n' "${cuda_major}" ;; + *) + echo "unsupported architecture '${arch}'; expected x86_64 or aarch64" >&2 + return 1 + ;; + esac +} + +# The directory a JAR for this classifier expects its native library under, which is also the +# resource path the loader searches at run time. +cuopt_java_native_resource_dir() { + local arch="${1:-$(uname -m)}" + case "${arch}" in + x86_64 | amd64) printf 'amd64/Linux\n' ;; + aarch64 | arm64) printf 'aarch64/Linux\n' ;; + *) + echo "unsupported architecture '${arch}'; expected x86_64 or aarch64" >&2 + return 1 + ;; + esac +} From bdb8905f166c80214617606ef46e81e4d4b39f8d Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 27 Aug 2026 11:23:45 -0500 Subject: [PATCH 02/31] Load the native library from the JAR, and package classifier JARs A published JAR is the only thing a consumer installs, so the library has to come out of it. NativeLibraryLoader keeps -Dcuopt.native.dir first for a library built from source, then falls back to a copy embedded in the JAR, then to the library path. The embedded copy is extracted once per user and reused when the size already matches, since re-extracting hundreds of megabytes on every JVM start would dominate startup. build_cuopt_java_jar.sh packages one classifier, placing the library where the loader looks. It refuses a library that still carries a DT_NEEDED on libcuopt.so: that loads on the build machine and fails for a consumer who installed nothing else, which is the whole failure this is meant to remove. The POM gains a classifier and a native-resource directory, both empty by default so the source build and the test suite are unchanged. Contributes to #1817. Signed-off-by: Ramakrishna Prabhu --- java/cuopt/ci/build_cuopt_java_jar.sh | 96 +++++++++++++++ java/cuopt/pom.xml | 14 +++ .../mathematicaloptimization/NativeCuOpt.java | 9 +- .../NativeLibraryLoader.java | 113 ++++++++++++++++++ java/cuopt/src/main/no-native/.gitkeep | 0 .../NativeLibraryLoaderTest.java | 50 ++++++++ 6 files changed, 274 insertions(+), 8 deletions(-) create mode 100755 java/cuopt/ci/build_cuopt_java_jar.sh create mode 100644 java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java create mode 100644 java/cuopt/src/main/no-native/.gitkeep create mode 100644 java/cuopt/src/test/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoaderTest.java diff --git a/java/cuopt/ci/build_cuopt_java_jar.sh b/java/cuopt/ci/build_cuopt_java_jar.sh new file mode 100755 index 0000000000..600e12b7e6 --- /dev/null +++ b/java/cuopt/ci/build_cuopt_java_jar.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Packages one classifier JAR: the Java classes plus the native library for a single +# CUDA-major/architecture pair, laid out where NativeLibraryLoader looks for it. +# +# The library placed here must be self-contained, because the JAR is the only thing a consumer +# installs. Build it with build_static_libcuopt.sh; see #1817. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODULE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=java/cuopt/ci/java_classifier.sh +source "${SCRIPT_DIR}/java_classifier.sh" + +NATIVE_LIB="" +CUDA_VERSION="" +OUTPUT_DIR="" +ARCH="$(uname -m)" + +print_help() { + cat << 'EOF' +Usage: build_cuopt_java_jar.sh --native-lib --cuda-version --output-dir + +Packages a single self-contained cuOpt Java classifier JAR. + +REQUIRED: + -n, --native-lib Path to the built libcuopt_jni.so to embed. + -c, --cuda-version CUDA version the library was built against, e.g. 13.0.3 or 13. + Its major version becomes part of the classifier. + -o, --output-dir Directory to receive / with the JAR and its POM. + +OPTIONS: + -a, --arch Target architecture (default: uname -m). + -h, --help Show this message. +EOF +} + +while [[ $# -gt 0 ]]; do + case $1 in + -h | --help) print_help; exit 0 ;; + -n | --native-lib) NATIVE_LIB="${2:?--native-lib needs a value}"; shift 2 ;; + -c | --cuda-version) CUDA_VERSION="${2:?--cuda-version needs a value}"; shift 2 ;; + -o | --output-dir) OUTPUT_DIR="${2:?--output-dir needs a value}"; shift 2 ;; + -a | --arch) ARCH="${2:?--arch needs a value}"; shift 2 ;; + *) echo "Unknown argument: $1" >&2; print_help >&2; exit 2 ;; + esac +done + +: "${NATIVE_LIB:?--native-lib is required}" +: "${CUDA_VERSION:?--cuda-version is required}" +: "${OUTPUT_DIR:?--output-dir is required}" + +if [[ ! -f "${NATIVE_LIB}" ]]; then + echo "native library not found: ${NATIVE_LIB}" >&2 + exit 1 +fi + +CLASSIFIER="$(cuopt_java_classifier "${CUDA_VERSION}" "${ARCH}")" +RESOURCE_DIR="$(cuopt_java_native_resource_dir "${ARCH}")" + +# A library that still needs libcuopt.so alongside it would load on the build machine and fail +# for a consumer who installed nothing else, so refuse to ship one. +if readelf -d "${NATIVE_LIB}" 2>/dev/null | grep -q 'NEEDED.*libcuopt\.so'; then + echo "ERROR: ${NATIVE_LIB} still has a DT_NEEDED on libcuopt.so." >&2 + echo " A classifier JAR must embed a self-contained library; link the static" >&2 + echo " archive from build_static_libcuopt.sh instead. See #1817." >&2 + exit 1 +fi + +STAGING="$(mktemp -d)" +trap 'rm -rf "${STAGING}"' EXIT +mkdir -p "${STAGING}/${RESOURCE_DIR}" +cp "${NATIVE_LIB}" "${STAGING}/${RESOURCE_DIR}/libcuopt_jni.so" + +echo "Packaging classifier ${CLASSIFIER}" +echo " native library -> ${RESOURCE_DIR}/libcuopt_jni.so" + +mkdir -p "${OUTPUT_DIR}/${CLASSIFIER}" +mvn -f "${MODULE_DIR}/pom.xml" -B \ + -DskipTests \ + -Dcuopt.jar.classifier="${CLASSIFIER}" \ + -Dcuopt.native.resources="${STAGING}" \ + package + +VERSION="$(mvn -f "${MODULE_DIR}/pom.xml" -B -q \ + -Dexec.executable=echo -Dexec.args='${project.version}' \ + --non-recursive exec:exec 2>/dev/null | tail -1)" + +cp "${MODULE_DIR}/target/cuopt-${VERSION}-${CLASSIFIER}.jar" "${OUTPUT_DIR}/${CLASSIFIER}/" +cp "${MODULE_DIR}/pom.xml" "${OUTPUT_DIR}/${CLASSIFIER}/cuopt-${VERSION}.pom" + +jar_mb=$(( $(stat -c%s "${OUTPUT_DIR}/${CLASSIFIER}/cuopt-${VERSION}-${CLASSIFIER}.jar") / 1048576 )) +echo " wrote ${OUTPUT_DIR}/${CLASSIFIER}/cuopt-${VERSION}-${CLASSIFIER}.jar (${jar_mb} MB)" diff --git a/java/cuopt/pom.xml b/java/cuopt/pom.xml index 019cc08433..35a7a48227 100644 --- a/java/cuopt/pom.xml +++ b/java/cuopt/pom.xml @@ -43,6 +43,12 @@ SPDX-License-Identifier: Apache-2.0 11 UTF-8 5.11.4 + + + + ${project.basedir}/src/main/no-native @@ -55,11 +61,19 @@ SPDX-License-Identifier: Apache-2.0 + + + ${cuopt.native.resources} + + org.apache.maven.plugins maven-jar-plugin 3.4.2 + + ${cuopt.jar.classifier} + org.apache.maven.plugins diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeCuOpt.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeCuOpt.java index abfa374dbe..8c0c29d1b1 100644 --- a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeCuOpt.java +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeCuOpt.java @@ -4,16 +4,9 @@ */ package com.nvidia.cuopt.mathematicaloptimization; -import java.nio.file.Path; - final class NativeCuOpt { static { - String nativeDir = System.getProperty("cuopt.native.dir"); - if (nativeDir == null || nativeDir.isBlank()) { - System.loadLibrary("cuopt_jni"); - } else { - System.load(Path.of(nativeDir, System.mapLibraryName("cuopt_jni")).toAbsolutePath().toString()); - } + NativeLibraryLoader.load(); } private NativeCuOpt() {} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java new file mode 100644 index 0000000000..50d9f84826 --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java @@ -0,0 +1,113 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicaloptimization; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +/** + * Locates and loads {@code libcuopt_jni}, in three steps. + * + *
    + *
  1. {@code -Dcuopt.native.dir}, for a library built from source; + *
  2. a copy embedded in this JAR, which is how the classifier artifacts ship; + *
  3. {@code System.loadLibrary}, for a library already on the library path. + *
+ */ +final class NativeLibraryLoader { + private static final String LIBRARY_NAME = "cuopt_jni"; + + private NativeLibraryLoader() {} + + static void load() { + String nativeDir = System.getProperty("cuopt.native.dir"); + if (nativeDir != null && !nativeDir.isBlank()) { + System.load(Path.of(nativeDir, System.mapLibraryName(LIBRARY_NAME)).toAbsolutePath().toString()); + return; + } + + Path embedded = extractEmbeddedLibrary(); + if (embedded != null) { + System.load(embedded.toString()); + return; + } + + System.loadLibrary(LIBRARY_NAME); + } + + /** + * The path an embedded library occupies, which is also the layout the packaging step writes. + * {@code os.arch} reports {@code amd64} on x86_64 JVMs and {@code aarch64} on ARM ones. + */ + static String resourcePath(String osArch, String libraryFileName) { + String directory; + switch (osArch) { + case "amd64": + case "x86_64": + directory = "amd64"; + break; + case "aarch64": + case "arm64": + directory = "aarch64"; + break; + default: + throw new IllegalStateException( + "cuOpt has no native library for architecture '" + osArch + "'"); + } + return "/" + directory + "/Linux/" + libraryFileName; + } + + /** + * Copies the embedded library out of the JAR, or returns null when this JAR does not carry one. + * + *

The library is written to a per-user directory keyed by its name and size rather than to a + * fresh temporary file, because it is hundreds of megabytes and re-extracting it on every JVM + * start would dominate startup. + */ + private static Path extractEmbeddedLibrary() { + String fileName = System.mapLibraryName(LIBRARY_NAME); + String resource; + try { + resource = resourcePath(System.getProperty("os.arch", ""), fileName); + } catch (IllegalStateException e) { + return null; + } + + URL url = NativeLibraryLoader.class.getResource(resource); + if (url == null) { + return null; + } + + try { + Path directory = + Path.of(System.getProperty("java.io.tmpdir"), "cuopt-native-" + System.getProperty("user.name", "shared")); + Files.createDirectories(directory); + Path target = directory.resolve(fileName); + + long expectedSize = url.openConnection().getContentLengthLong(); + if (expectedSize >= 0 && Files.isRegularFile(target) && Files.size(target) == expectedSize) { + return target; + } + + // A partially written file from an interrupted run would fail to load, so write to a + // sibling first and move it into place, which is atomic on the same filesystem. + Path staging = Files.createTempFile(directory, fileName + ".", ".part"); + try (InputStream in = url.openStream()) { + Files.copy(in, staging, StandardCopyOption.REPLACE_EXISTING); + Files.move(staging, target, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(staging); + } + return target; + } catch (IOException e) { + throw new UncheckedIOException("failed to extract " + resource + " from the cuOpt JAR", e); + } + } +} diff --git a/java/cuopt/src/main/no-native/.gitkeep b/java/cuopt/src/main/no-native/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoaderTest.java b/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoaderTest.java new file mode 100644 index 0000000000..780f8d0536 --- /dev/null +++ b/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoaderTest.java @@ -0,0 +1,50 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * The resource path has to agree with the layout the packaging step writes, and neither side can + * see the other, so pin it here. + */ +final class NativeLibraryLoaderTest { + @Test + void mapsJvmArchitecturesToThePackagedResourcePath() { + // os.arch reports amd64 on an x86_64 JVM, but the build host reports x86_64; both appear. + assertEquals( + "/amd64/Linux/libcuopt_jni.so", + NativeLibraryLoader.resourcePath("amd64", "libcuopt_jni.so")); + assertEquals( + "/amd64/Linux/libcuopt_jni.so", + NativeLibraryLoader.resourcePath("x86_64", "libcuopt_jni.so")); + assertEquals( + "/aarch64/Linux/libcuopt_jni.so", + NativeLibraryLoader.resourcePath("aarch64", "libcuopt_jni.so")); + assertEquals( + "/aarch64/Linux/libcuopt_jni.so", + NativeLibraryLoader.resourcePath("arm64", "libcuopt_jni.so")); + } + + @Test + void rejectsAnArchitectureCuOptDoesNotPublish() { + IllegalStateException error = + assertThrows( + IllegalStateException.class, + () -> NativeLibraryLoader.resourcePath("ppc64le", "libcuopt_jni.so")); + assertTrue(error.getMessage().contains("ppc64le")); + } + + @Test + void theLibraryThisSuiteRunsAgainstIsLoadable() { + // Reaching any native method proves whichever strategy applied here resolved the library: + // cuopt.native.dir for a source build, the embedded copy for a classifier JAR. + assertEquals(8, NativeCuOpt.getFloatSize()); + } +} From e521ce7337694b1d07320f12e87504db86e060a0 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 27 Aug 2026 13:23:49 -0500 Subject: [PATCH 03/31] Produce a working self-contained classifier JAR Linking libcuopt_static.a into cuopt_jni removes the dependency on libcuopt.so, but not on the libraries cuOpt itself needs and conda ships only as shared objects. Each surfaced as an UnsatisfiedLinkError in turn: rmm's exception typeinfo, TBB via KaMinPar, NCCL, then cuDSS. They are linked and packaged beside the JNI library, which finds them through its $ORIGIN RPATH, and the loader lays them out before loading it. NCCL is 279 MB of the 405 MB result and is only needed for distributed PDLP, which a Java JAR cannot reach. cpp/CMakeLists.txt has no switch to compile that path out; adding one is the single biggest size win available. The shared libcuopt path is untouched: CUOPT_STATIC_BUILD_DIR is empty by default, cuopt.native.dir is still tried first, and the source build still passes 35/35. Contributes to #1817. Signed-off-by: Ramakrishna Prabhu --- dependencies.yaml | 13 ++++ java/cuopt/CMakeLists.txt | 42 +++++++++- java/cuopt/ci/build_cuopt_java_jar.sh | 13 ++++ .../NativeLibraryLoader.java | 77 ++++++++++++------- 4 files changed, 115 insertions(+), 30 deletions(-) diff --git a/dependencies.yaml b/dependencies.yaml index c484aa2180..bf74995554 100644 --- a/dependencies.yaml +++ b/dependencies.yaml @@ -58,6 +58,19 @@ files: - depends_on_librmm - depends_on_rapids_logger - java + java_static: + output: none + includes: + # libcuopt is compiled from source here rather than installed, so this needs the C++ + # build dependencies (tbb-devel, nccl, zlib, bzip2) and not depends_on_libcuopt. + - build_common + - build_cpp + - cuda + - cuda_version + - depends_on_libraft_headers + - depends_on_librmm + - depends_on_rapids_logger + - java test_python: output: none includes: diff --git a/java/cuopt/CMakeLists.txt b/java/cuopt/CMakeLists.txt index 7b1a40c714..1109807c92 100644 --- a/java/cuopt/CMakeLists.txt +++ b/java/cuopt/CMakeLists.txt @@ -33,13 +33,27 @@ set(CUOPT_INCLUDE_DIR "${CUOPT_PREFIX}/include") # (rmm::_RMM_26_10), so mixing a different copy links cleanly and then fails at dlopen with an # undefined symbol. set(CUOPT_LIBRARY "" CACHE FILEPATH "Path to libcuopt.so (defaults to CUOPT_PREFIX/lib)") +# Embedding cuOpt into libcuopt_jni.so instead of linking the shared library, so a published +# classifier JAR carries everything it needs. See #1817. +set(CUOPT_STATIC_BUILD_DIR "" CACHE PATH + "Build tree holding libcuopt_static.a; when set, cuOpt is linked into cuopt_jni statically") set(CUOPT_EXTRA_INCLUDE_DIRS "" CACHE STRING "Extra include directories searched before CUOPT_PREFIX/include") -if(NOT CUOPT_LIBRARY) +if(CUOPT_STATIC_BUILD_DIR) + set(CUOPT_STATIC_ARCHIVE "${CUOPT_STATIC_BUILD_DIR}/libcuopt_static.a") + if(NOT EXISTS "${CUOPT_STATIC_ARCHIVE}") + message(FATAL_ERROR "libcuopt_static.a was not found in ${CUOPT_STATIC_BUILD_DIR}") + endif() + # Fetched and built alongside cuOpt rather than installed, so they are found by path. + file(GLOB CUOPT_STATIC_DEP_ARCHIVES + "${CUOPT_STATIC_BUILD_DIR}/_deps/pslp-build/libPSLP.a" + "${CUOPT_STATIC_BUILD_DIR}/_deps/kaminpar-build/kaminpar-shm/libKaMinPar.a" + "${CUOPT_STATIC_BUILD_DIR}/_deps/kaminpar-build/kaminpar-common/libKaMinParCommon.a") +elseif(NOT CUOPT_LIBRARY) set(CUOPT_LIBRARY "${CUOPT_PREFIX}/lib/libcuopt.so") endif() -if(NOT EXISTS "${CUOPT_LIBRARY}") +if(NOT CUOPT_STATIC_BUILD_DIR AND NOT EXISTS "${CUOPT_LIBRARY}") message(FATAL_ERROR "cuOpt shared library was not found at ${CUOPT_LIBRARY}") endif() @@ -60,7 +74,29 @@ target_include_directories(cuopt_jni PRIVATE ${CUDAToolkit_INCLUDE_DIRS} ${CMAKE_CURRENT_SOURCE_DIR}/../../cpp/src) -target_link_libraries(cuopt_jni PRIVATE "${CUOPT_LIBRARY}" CUDA::cudart) +if(CUOPT_STATIC_BUILD_DIR) + find_package(OpenMP REQUIRED) + # The JNI layer references only a fraction of cuOpt directly; the rest is reached through + # registrations and virtual dispatch, so the archive has to be kept whole. + target_link_libraries(cuopt_jni PRIVATE + -Wl,--whole-archive "${CUOPT_STATIC_ARCHIVE}" -Wl,--no-whole-archive + ${CUOPT_STATIC_DEP_ARCHIVES} + CUDA::cublas CUDA::cusparse CUDA::cusolver CUDA::cudart_static + OpenMP::OpenMP_CXX + # rmm is mostly header-only, but its exception types are defined in librmm.so, and + # rapids_logger likewise. Conda ships no static variant of either, so they are linked + # shared and packaged next to cuopt_jni, which finds them through its $ORIGIN RPATH. + "${CUOPT_PREFIX}/lib/librmm.so" + "${CUOPT_PREFIX}/lib/librapids_logger.so" + # KaMinPar, which the static archive pulls in, throws through TBB. + "${CUOPT_PREFIX}/lib/libtbb.so.12" + # PDLP's distributed path references NCCL unconditionally; cpp/CMakeLists.txt has no + # switch to compile it out, so it has to be linked even for a single-GPU JAR. + "${CUOPT_PREFIX}/lib/libnccl.so.2" + "${CUOPT_PREFIX}/lib/libcudss.so.0") +else() + target_link_libraries(cuopt_jni PRIVATE "${CUOPT_LIBRARY}" CUDA::cudart) +endif() # The Java module is built outside the main cuOpt build. Keep its native # loader self-contained while allowing the script to add the cuOpt runtime diff --git a/java/cuopt/ci/build_cuopt_java_jar.sh b/java/cuopt/ci/build_cuopt_java_jar.sh index 600e12b7e6..e769fef1cf 100755 --- a/java/cuopt/ci/build_cuopt_java_jar.sh +++ b/java/cuopt/ci/build_cuopt_java_jar.sh @@ -78,6 +78,19 @@ cp "${NATIVE_LIB}" "${STAGING}/${RESOURCE_DIR}/libcuopt_jni.so" echo "Packaging classifier ${CLASSIFIER}" echo " native library -> ${RESOURCE_DIR}/libcuopt_jni.so" +# rmm and rapids_logger define the exception types cuOpt throws and have no static build, so +# they ship beside the JNI library, which finds them through its $ORIGIN RPATH. +for companion in librmm.so librapids_logger.so libtbb.so.12 libnccl.so.2 libcudss.so.0; do + companion_path="${CUOPT_PREFIX:-}/lib/${companion}" + if [[ ! -f "${companion_path}" ]]; then + echo "ERROR: ${companion} not found at ${companion_path}; set CUOPT_PREFIX" >&2 + exit 1 + fi + # Dereference, since the conda entries are symlinks into a versioned file. + cp -L "${companion_path}" "${STAGING}/${RESOURCE_DIR}/${companion}" + echo " companion -> ${RESOURCE_DIR}/${companion}" +done + mkdir -p "${OUTPUT_DIR}/${CLASSIFIER}" mvn -f "${MODULE_DIR}/pom.xml" -B \ -DskipTests \ diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java index 50d9f84826..1d92a2a497 100644 --- a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java @@ -24,16 +24,20 @@ final class NativeLibraryLoader { private static final String LIBRARY_NAME = "cuopt_jni"; + /** rmm, rapids_logger and TBB have no static build, so they travel beside the JNI library. */ + private static final String[] COMPANION_LIBRARIES = {"librmm.so", "librapids_logger.so", "libtbb.so.12", "libnccl.so.2", "libcudss.so.0"}; + private NativeLibraryLoader() {} static void load() { String nativeDir = System.getProperty("cuopt.native.dir"); if (nativeDir != null && !nativeDir.isBlank()) { - System.load(Path.of(nativeDir, System.mapLibraryName(LIBRARY_NAME)).toAbsolutePath().toString()); + System.load( + Path.of(nativeDir, System.mapLibraryName(LIBRARY_NAME)).toAbsolutePath().toString()); return; } - Path embedded = extractEmbeddedLibrary(); + Path embedded = extractEmbeddedLibraries(); if (embedded != null) { System.load(embedded.toString()); return; @@ -65,49 +69,68 @@ static String resourcePath(String osArch, String libraryFileName) { } /** - * Copies the embedded library out of the JAR, or returns null when this JAR does not carry one. + * Copies the packaged libraries out of the JAR and returns the path of the JNI one, or null when + * this JAR does not carry them. * - *

The library is written to a per-user directory keyed by its name and size rather than to a - * fresh temporary file, because it is hundreds of megabytes and re-extracting it on every JVM - * start would dominate startup. + *

The companions are not loaded here. The JNI library's {@code $ORIGIN} RPATH resolves them + * once they sit in the same directory, so they only have to be on disk before it is loaded. */ - private static Path extractEmbeddedLibrary() { + private static Path extractEmbeddedLibraries() { + String osArch = System.getProperty("os.arch", ""); String fileName = System.mapLibraryName(LIBRARY_NAME); String resource; try { - resource = resourcePath(System.getProperty("os.arch", ""), fileName); + resource = resourcePath(osArch, fileName); } catch (IllegalStateException e) { return null; } - - URL url = NativeLibraryLoader.class.getResource(resource); - if (url == null) { + if (NativeLibraryLoader.class.getResource(resource) == null) { return null; } try { Path directory = - Path.of(System.getProperty("java.io.tmpdir"), "cuopt-native-" + System.getProperty("user.name", "shared")); + Path.of( + System.getProperty("java.io.tmpdir"), + "cuopt-native-" + System.getProperty("user.name", "shared")); Files.createDirectories(directory); - Path target = directory.resolve(fileName); - long expectedSize = url.openConnection().getContentLengthLong(); - if (expectedSize >= 0 && Files.isRegularFile(target) && Files.size(target) == expectedSize) { - return target; + for (String companion : COMPANION_LIBRARIES) { + extractResource(resourcePath(osArch, companion), directory, companion); } + return extractResource(resource, directory, fileName); + } catch (IOException e) { + throw new UncheckedIOException("failed to extract native libraries from the cuOpt JAR", e); + } + } - // A partially written file from an interrupted run would fail to load, so write to a - // sibling first and move it into place, which is atomic on the same filesystem. - Path staging = Files.createTempFile(directory, fileName + ".", ".part"); - try (InputStream in = url.openStream()) { - Files.copy(in, staging, StandardCopyOption.REPLACE_EXISTING); - Files.move(staging, target, StandardCopyOption.REPLACE_EXISTING); - } finally { - Files.deleteIfExists(staging); - } + /** + * Copies one packaged file into {@code directory} and returns it, or null when the JAR does not + * contain it. + * + *

A file already there with the expected size is reused rather than rewritten, because the JNI + * library is hundreds of megabytes and re-extracting it on every JVM start would dominate + * startup. It is written to a sibling and moved into place, so an interrupted run cannot leave a + * truncated library behind for the next one to load. + */ + private static Path extractResource(String resource, Path directory, String fileName) + throws IOException { + URL url = NativeLibraryLoader.class.getResource(resource); + if (url == null) { + return null; + } + Path target = directory.resolve(fileName); + long expectedSize = url.openConnection().getContentLengthLong(); + if (expectedSize >= 0 && Files.isRegularFile(target) && Files.size(target) == expectedSize) { return target; - } catch (IOException e) { - throw new UncheckedIOException("failed to extract " + resource + " from the cuOpt JAR", e); } + Path staging = Files.createTempFile(directory, fileName + ".", ".part"); + try (InputStream in = url.openStream()) { + Files.copy(in, staging, StandardCopyOption.REPLACE_EXISTING); + Files.move(staging, target, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(staging); + } + return target; } } From 2f626af1a0e09bf6c092ebd2b1ed587c5a0b9307 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 27 Aug 2026 13:38:54 -0500 Subject: [PATCH 04/31] Build and verify the classifier JAR in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci/build_java_static.sh runs the whole path — static libcuopt, static link, packaging — and then checks the result. java-static-build runs it alongside java-build, which still covers the shared libcuopt path. verify_jar_dependencies.sh is the check worth having. Every missing library found while getting this working (rmm, TBB, NCCL, cuDSS) appeared only as an UnsatisfiedLinkError at run time, because the build environment supplies them all and the JAR looks fine there. It reads DT_NEEDED and allows only what is packaged in the JAR, provided by the CUDA toolkit, or part of the base system. Reading DT_NEEDED rather than resolving against a library directory matters: the first version pointed ldd at the conda prefix and passed a JAR with libnccl.so.2 deleted, since the prefix contains it either way. Contributes to #1817. Signed-off-by: Ramakrishna Prabhu --- .github/workflows/build.yaml | 21 ++++ ci/build_java_static.sh | 66 ++++++++++++ java/cuopt/ci/verify_jar_dependencies.sh | 122 +++++++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100755 ci/build_java_static.sh create mode 100755 java/cuopt/ci/verify_jar_dependencies.sh diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 264fb5398c..6fd1f9a41f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -64,6 +64,27 @@ jobs: date: ${{ inputs.date }} sha: ${{ inputs.sha }} script: ci/build_cpp.sh + # Exploratory: builds the self-contained classifier JAR from #1817 and checks it carries + # every native library it needs. Independent of java-build, which links the shared libcuopt. + java-static-build: + permissions: + actions: read + contents: read + id-token: write + packages: read + pull-requests: read + secrets: inherit # zizmor: ignore[secrets-inherit] + uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@main + with: + build_type: ${{ inputs.build_type || 'branch' }} + branch: ${{ inputs.branch }} + date: ${{ inputs.date }} + sha: ${{ inputs.sha }} + node_type: "cpu16" + arch: "amd64" + container_image: "rapidsai/ci-conda:26.10-latest" + script: "ci/build_java_static.sh" + java-build: needs: cpp-build permissions: diff --git a/ci/build_java_static.sh b/ci/build_java_static.sh new file mode 100755 index 0000000000..508c8eadcf --- /dev/null +++ b/ci/build_java_static.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Builds a self-contained Java classifier JAR and checks that it is actually self-contained. +# +# Unlike ci/build_java.sh, which installs a prebuilt libcuopt and links it as a shared library, +# this compiles libcuopt from source as a static archive and embeds it, so the JAR is the only +# thing a consumer installs. See #1817. + +set -euo pipefail + +if [[ -e /opt/conda/etc/profile.d/conda.sh ]]; then + . /opt/conda/etc/profile.d/conda.sh +fi + +rapids-logger "Configuring conda strict channel priority" +conda config --set channel_priority strict + +rapids-logger "Generating Java static build dependencies" +ENV_YAML_DIR=$(mktemp -d) +rapids-dependency-file-generator \ + --output conda \ + --file-key java_static \ + --matrix "cuda=${RAPIDS_CUDA_VERSION%.*};arch=$(arch)" | tee "${ENV_YAML_DIR}/env.yaml" + +rapids-mamba-retry env create --yes -f "${ENV_YAML_DIR}/env.yaml" -n java_static + +# Temporarily allow unbound variables for conda activation. +set +u +conda activate java_static +set -u + +rapids-print-env + +export CUOPT_PREFIX="${CONDA_PREFIX}" +STATIC_BUILD_DIR="${PWD}/cpp/build-static" +JNI_BUILD_DIR="${PWD}/java/cuopt/build/native-static" +JAR_OUTPUT_DIR="${PWD}/java/cuopt/classifier-jars" + +rapids-logger "Building the scoped static libcuopt" +BUILD_DIR="${STATIC_BUILD_DIR}" bash java/cuopt/ci/build_static_libcuopt.sh + +rapids-logger "Linking libcuopt into cuopt_jni" +cmake -S java/cuopt -B "${JNI_BUILD_DIR}" -GNinja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCUOPT_PREFIX="${CUOPT_PREFIX}" \ + -DCUOPT_STATIC_BUILD_DIR="${STATIC_BUILD_DIR}" \ + -DCUOPT_EXTRA_INCLUDE_DIRS="${PWD}/cpp/include;${STATIC_BUILD_DIR}/include" +cmake --build "${JNI_BUILD_DIR}" --parallel "${PARALLEL_LEVEL:-$(nproc)}" + +rapids-logger "Packaging the classifier JAR" +bash java/cuopt/ci/build_cuopt_java_jar.sh \ + --native-lib "${JNI_BUILD_DIR}/libcuopt_jni.so" \ + --cuda-version "${RAPIDS_CUDA_VERSION}" \ + --output-dir "${JAR_OUTPUT_DIR}" + +# The JAR looking fine on this machine proves nothing: the build environment supplies every +# dependency by construction. This resolves them the way a consumer's machine would. +rapids-logger "Verifying the JAR is self-contained" +CLASSIFIER_JAR=$(find "${JAR_OUTPUT_DIR}" -name 'cuopt-*.jar' -print -quit) +bash java/cuopt/ci/verify_jar_dependencies.sh --jar "${CLASSIFIER_JAR}" + +rapids-logger "Result" +du -h "${CLASSIFIER_JAR}" | sed 's/^/ /' diff --git a/java/cuopt/ci/verify_jar_dependencies.sh b/java/cuopt/ci/verify_jar_dependencies.sh new file mode 100755 index 0000000000..1600715238 --- /dev/null +++ b/java/cuopt/ci/verify_jar_dependencies.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Checks that a classifier JAR can satisfy its own native dependencies. +# +# A JAR is only self-contained if every library it needs is either inside it, part of the CUDA +# toolkit the classifier names, or part of the base system. Anything else resolves on a build +# machine, because the build environment happens to have it, and fails for a consumer who +# installed only the JAR. Linking libcuopt statically surfaced four such libraries one at a time +# (rmm, TBB, NCCL, cuDSS), each as an UnsatisfiedLinkError at run time; this catches that class +# of gap at build time instead. +# +# The check reads DT_NEEDED rather than resolving against a directory, because a build +# environment's lib directory contains every dependency by construction and would make any JAR +# look self-contained. + +set -euo pipefail + +JAR="" + +# Supplied by the CUDA toolkit a consumer installs for the classifier's CUDA major version. +ALLOWED_CUDA_LIBRARIES=( + libcublas.so libcublasLt.so libcusparse.so libcusolver.so libcudart.so libcurand.so + libnvJitLink.so libnvrtc.so libcuda.so +) + +# Present on any Linux that can run a JVM. +ALLOWED_SYSTEM_LIBRARIES=( + libc.so libm.so libdl.so librt.so libpthread.so libstdc++.so libgcc_s.so libgomp.so + ld-linux-x86-64.so ld-linux-aarch64.so libresolv.so +) + +print_help() { + cat << 'EOF' +Usage: verify_jar_dependencies.sh --jar + +Fails if the JAR's native libraries need anything that is neither packaged inside it, nor part +of the CUDA toolkit, nor part of the base system. + +REQUIRED: + -j, --jar Classifier JAR to check. +EOF +} + +while [[ $# -gt 0 ]]; do + case $1 in + -h | --help) print_help; exit 0 ;; + -j | --jar) JAR="${2:?--jar needs a value}"; shift 2 ;; + *) echo "Unknown argument: $1" >&2; print_help >&2; exit 2 ;; + esac +done + +: "${JAR:?--jar is required}" +if [[ ! -f "${JAR}" ]]; then + echo "JAR not found: ${JAR}" >&2 + exit 1 +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "${WORK}"' EXIT +unzip -q "${JAR}" '*/Linux/*.so*' -d "${WORK}" 2>/dev/null || true + +JNI_LIB="$(find "${WORK}" -name 'libcuopt_jni.so' -print -quit)" +if [[ -z "${JNI_LIB}" ]]; then + echo "ERROR: ${JAR} contains no libcuopt_jni.so" >&2 + exit 1 +fi +NATIVE_DIR="$(dirname "${JNI_LIB}")" + +echo "Packaged libraries:" +while read -r lib; do + printf ' %6s MB %s\n' "$(( $(stat -c%s "${NATIVE_DIR}/${lib}") / 1048576 ))" "${lib}" +done < <(cd "${NATIVE_DIR}" && ls -S ./*.so* | sed 's|^\./||') + +# Strip the version suffix so libnccl.so.2 matches an allowlist entry of libnccl.so. +soname_stem() { sed -E 's/\.so\.[0-9.]+$/.so/' <<< "$1"; } + +allowed_external=("${ALLOWED_CUDA_LIBRARIES[@]}" "${ALLOWED_SYSTEM_LIBRARIES[@]}") +unsatisfied=() + +echo +echo "Checking DT_NEEDED of every packaged library" +for lib in "${NATIVE_DIR}"/*.so*; do + while read -r needed; do + [[ -z "${needed}" ]] && continue + # Packaged beside it, so the $ORIGIN RPATH resolves it. + if [[ -e "${NATIVE_DIR}/${needed}" ]]; then + continue + fi + stem="$(soname_stem "${needed}")" + permitted=false + for allowed in "${allowed_external[@]}"; do + if [[ "${stem}" == "${allowed}" ]]; then + permitted=true + break + fi + done + if [[ "${permitted}" == false ]]; then + unsatisfied+=("$(basename "${lib}") needs ${needed}") + fi + done < <(readelf -d "${lib}" 2>/dev/null | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p') +done + +if [[ ${#unsatisfied[@]} -gt 0 ]]; then + echo >&2 + echo "ERROR: the JAR is not self-contained. Unsatisfied dependencies:" >&2 + printf ' %s\n' "${unsatisfied[@]}" | sort -u >&2 + echo >&2 + echo "Each must be linked into the JNI library or packaged beside it; see" >&2 + echo "build_cuopt_java_jar.sh. A consumer installs nothing but this JAR." >&2 + exit 1 +fi + +# libcuopt.so reappearing means the static link silently fell back to the shared library. +if readelf -d "${JNI_LIB}" | grep -q 'NEEDED.*libcuopt\.so'; then + echo "ERROR: libcuopt_jni.so depends on libcuopt.so; it was not linked statically." >&2 + exit 1 +fi + +echo +echo "Self-contained: every dependency is packaged, CUDA toolkit, or base system." From a0816cb8bad77c45cebb365b3761ae56e6347ade Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 27 Aug 2026 14:02:09 -0500 Subject: [PATCH 05/31] Run java-static-build on pull requests The job was only in build.yaml, which runs on branch and nightly builds, so it would never have run on the PR proposing it. pr.yaml now runs it too, gated on the same test_java and test_cpp file groups as java-build and included in the pr-builder aggregator so a failure fails the PR. It runs on cpu16 rather than a GPU node: the job compiles the JAR and inspects its dependencies, but does not execute it. Signed-off-by: Ramakrishna Prabhu --- .github/workflows/pr.yaml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index ba50abcbce..ca458720b4 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -24,6 +24,7 @@ jobs: - conda-cpp-build - conda-cpp-tests - java-build + - java-static-build - conda-python-build - conda-python-tests - docs-build @@ -113,6 +114,7 @@ jobs: - '!SECURITY.md' - '!ci/build_wheel*.sh' - '!ci/build_java.sh' + - '!ci/build_java_static.sh' - '!ci/check_style.sh' - '!ci/docker/**' - '!ci/release/**' @@ -178,6 +180,7 @@ jobs: - '!agents/**' - '!ci/build_docs.sh' - '!ci/build_java.sh' + - '!ci/build_java_static.sh' - '!ci/build_python.sh' - '!ci/build_wheel*.sh' - '!ci/check_style.sh' @@ -213,6 +216,7 @@ jobs: test_java: - 'java/**' - 'ci/build_java.sh' + - 'ci/build_java_static.sh' - 'ci/test_java.sh' - 'dependencies.yaml' - '.github/workflows/pr.yaml' @@ -262,6 +266,7 @@ jobs: - '!agents/**' - '!ci/build_docs.sh' - '!ci/build_java.sh' + - '!ci/build_java_static.sh' - '!ci/build_wheel*.sh' - '!ci/check_style.sh' - '!ci/docker/**' @@ -337,6 +342,7 @@ jobs: - '!ci/build_docs.sh' - '!ci/build_python.sh' - '!ci/build_java.sh' + - '!ci/build_java_static.sh' - '!ci/check_style.sh' - '!ci/docker/**' - '!ci/release/**' @@ -477,6 +483,28 @@ jobs: artifact-name: "cuopt_docs" container_image: "rapidsai/ci-conda:26.10-latest" script: "ci/build_docs.sh" + # Exploratory (#1817): builds the self-contained classifier JAR and checks it carries every + # native library it needs. Compiles libcuopt from source, so it does not need conda-cpp-build. + java-static-build: + needs: changed-files + permissions: + actions: read + contents: read + id-token: write + packages: read + pull-requests: read + secrets: inherit # zizmor: ignore[secrets-inherit] + uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@main + if: >- + fromJSON(needs.changed-files.outputs.changed_file_groups).test_java || + fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp + with: + build_type: pull-request + node_type: "cpu16" + arch: "amd64" + container_image: "rapidsai/ci-conda:26.10-latest" + script: "ci/build_java_static.sh" + java-build: needs: [conda-cpp-build, changed-files] permissions: From 87b17bb90984f7868b7de2c27401bc77300af4b4 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 27 Aug 2026 16:16:04 -0500 Subject: [PATCH 06/31] Upload the classifier JARs as one Maven-repository-layout artifact A publishing workflow consumes a Maven repository tree, so the shape is fixed here rather than left to whatever downloads these JARs. assemble_maven_repo.sh gathers the classifier JARs, the POM renamed from pom.xml to cuopt-.pom, and the sources and javadoc JARs that Maven Central requires, into com/nvidia/cuopt/cuopt//. It reads the version from a JAR name so the layout can only describe artifacts that exist, and refuses a non-empty output directory so a stale tree cannot be published. java-static-build uploads that tree as cuopt_java_maven_repo. Requested by @paul-aiyedun for the nightly Sonatype snapshot workflow in rapidsai/build-infra#379. Signed-off-by: Ramakrishna Prabhu --- .github/workflows/build.yaml | 4 + .github/workflows/pr.yaml | 4 + ci/build_java_static.sh | 9 ++ java/cuopt/ci/assemble_maven_repo.sh | 118 +++++++++++++++++++++++++++ 4 files changed, 135 insertions(+) create mode 100755 java/cuopt/ci/assemble_maven_repo.sh diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6fd1f9a41f..9508368df2 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -84,6 +84,10 @@ jobs: arch: "amd64" container_image: "rapidsai/ci-conda:26.10-latest" script: "ci/build_java_static.sh" + # One artifact in Maven repository layout, which is the form a publishing workflow + # consumes. See rapidsai/build-infra#379. + artifact-name: "cuopt_java_maven_repo" + file_to_upload: "java/cuopt/maven-repo/" java-build: needs: cpp-build diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index ca458720b4..22f3c27cf6 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -504,6 +504,10 @@ jobs: arch: "amd64" container_image: "rapidsai/ci-conda:26.10-latest" script: "ci/build_java_static.sh" + # One artifact in Maven repository layout, which is the form a publishing workflow + # consumes. See rapidsai/build-infra#379. + artifact-name: "cuopt_java_maven_repo" + file_to_upload: "java/cuopt/maven-repo/" java-build: needs: [conda-cpp-build, changed-files] diff --git a/ci/build_java_static.sh b/ci/build_java_static.sh index 508c8eadcf..64bd40dc78 100755 --- a/ci/build_java_static.sh +++ b/ci/build_java_static.sh @@ -38,6 +38,7 @@ export CUOPT_PREFIX="${CONDA_PREFIX}" STATIC_BUILD_DIR="${PWD}/cpp/build-static" JNI_BUILD_DIR="${PWD}/java/cuopt/build/native-static" JAR_OUTPUT_DIR="${PWD}/java/cuopt/classifier-jars" +MAVEN_REPO_DIR="${PWD}/java/cuopt/maven-repo" rapids-logger "Building the scoped static libcuopt" BUILD_DIR="${STATIC_BUILD_DIR}" bash java/cuopt/ci/build_static_libcuopt.sh @@ -62,5 +63,13 @@ rapids-logger "Verifying the JAR is self-contained" CLASSIFIER_JAR=$(find "${JAR_OUTPUT_DIR}" -name 'cuopt-*.jar' -print -quit) bash java/cuopt/ci/verify_jar_dependencies.sh --jar "${CLASSIFIER_JAR}" +# A single artifact in Maven repository layout is what a publishing workflow consumes, so the +# shape is fixed here rather than left to whatever downloads these JARs. +rapids-logger "Assembling the Maven repository layout" +bash java/cuopt/ci/assemble_maven_repo.sh \ + --jars-dir "${JAR_OUTPUT_DIR}" \ + --extra-jars-dir "${PWD}/java/cuopt/target" \ + --output-dir "${MAVEN_REPO_DIR}" + rapids-logger "Result" du -h "${CLASSIFIER_JAR}" | sed 's/^/ /' diff --git a/java/cuopt/ci/assemble_maven_repo.sh b/java/cuopt/ci/assemble_maven_repo.sh new file mode 100755 index 0000000000..4d5e06c2fb --- /dev/null +++ b/java/cuopt/ci/assemble_maven_repo.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Gathers per-classifier JARs into one Maven-repository-layout tree, which is the form a +# publishing workflow consumes. +# +# Input: one directory per classifier, as build_cuopt_java_jar.sh writes them, each holding +# cuopt--.jar +# cuopt-.pom +# Output: com/nvidia/cuopt/cuopt// holding every classifier JAR, the sources and +# javadoc JARs, and the POM named cuopt-.pom. +# +# The POM must be named after the artifact rather than left as pom.xml, and the sources and +# javadoc JARs are required by Maven Central, so a bundle missing either is rejected late. + +set -euo pipefail + +GROUP_PATH="com/nvidia/cuopt" +ARTIFACT_ID="cuopt" +JARS_DIR="" +OUTPUT_DIR="" +EXTRA_JARS_DIR="" + +print_help() { + cat << 'EOF' +Usage: assemble_maven_repo.sh --jars-dir --output-dir [--extra-jars-dir ] + +REQUIRED: + -j, --jars-dir Parent directory holding one subdirectory per classifier. + -o, --output-dir Directory to receive the Maven-repository layout. Must not exist + or must be empty, so a stale artifact cannot be published. + +OPTIONS: + -e, --extra-jars-dir Directory holding the sources and javadoc JARs, normally + java/cuopt/target. + -h, --help Show this message. +EOF +} + +while [[ $# -gt 0 ]]; do + case $1 in + -h | --help) print_help; exit 0 ;; + -j | --jars-dir) JARS_DIR="${2:?--jars-dir needs a value}"; shift 2 ;; + -o | --output-dir) OUTPUT_DIR="${2:?--output-dir needs a value}"; shift 2 ;; + -e | --extra-jars-dir) EXTRA_JARS_DIR="${2:?--extra-jars-dir needs a value}"; shift 2 ;; + *) echo "Unknown argument: $1" >&2; print_help >&2; exit 2 ;; + esac +done + +: "${JARS_DIR:?--jars-dir is required}" +: "${OUTPUT_DIR:?--output-dir is required}" + +if [[ ! -d "${JARS_DIR}" ]]; then + echo "jars directory not found: ${JARS_DIR}" >&2 + exit 1 +fi +if [[ -d "${OUTPUT_DIR}" && -n "$(ls -A "${OUTPUT_DIR}" 2>/dev/null)" ]]; then + echo "output directory ${OUTPUT_DIR} is not empty; remove it before re-running" >&2 + exit 1 +fi + +# The version is read from a JAR name rather than the POM, so the layout can only ever describe +# artifacts that are actually present. +first_jar="$(find "${JARS_DIR}" -name "${ARTIFACT_ID}-*.jar" -print -quit)" +if [[ -z "${first_jar}" ]]; then + echo "no ${ARTIFACT_ID}-*.jar found under ${JARS_DIR}" >&2 + exit 1 +fi +VERSION="$(basename "${first_jar}" | sed -E "s/^${ARTIFACT_ID}-([0-9][^-]*)-.*\.jar$/\1/")" +if [[ -z "${VERSION}" || "${VERSION}" == "$(basename "${first_jar}")" ]]; then + echo "could not read a version from $(basename "${first_jar}")" >&2 + exit 1 +fi + +TARGET="${OUTPUT_DIR}/${GROUP_PATH}/${ARTIFACT_ID}/${VERSION}" +mkdir -p "${TARGET}" +echo "Assembling ${GROUP_PATH}/${ARTIFACT_ID}/${VERSION}" + +classifiers=0 +while IFS= read -r jar; do + cp "${jar}" "${TARGET}/" + echo " $(basename "${jar}")" + classifiers=$((classifiers + 1)) +done < <(find "${JARS_DIR}" -name "${ARTIFACT_ID}-${VERSION}-*.jar" ! -name '*-sources.jar' ! -name '*-javadoc.jar' | sort) + +if [[ "${classifiers}" -eq 0 ]]; then + echo "no classifier JARs found for version ${VERSION}" >&2 + exit 1 +fi + +pom="$(find "${JARS_DIR}" -name "${ARTIFACT_ID}-${VERSION}.pom" -print -quit)" +if [[ -z "${pom}" ]]; then + echo "no ${ARTIFACT_ID}-${VERSION}.pom found under ${JARS_DIR}" >&2 + exit 1 +fi +cp "${pom}" "${TARGET}/${ARTIFACT_ID}-${VERSION}.pom" +echo " ${ARTIFACT_ID}-${VERSION}.pom" + +for kind in sources javadoc; do + extra="" + if [[ -n "${EXTRA_JARS_DIR}" ]]; then + extra="$(find "${EXTRA_JARS_DIR}" -name "${ARTIFACT_ID}-${VERSION}-${kind}.jar" -print -quit)" + fi + if [[ -z "${extra}" ]]; then + extra="$(find "${JARS_DIR}" -name "${ARTIFACT_ID}-${VERSION}-${kind}.jar" -print -quit)" + fi + if [[ -z "${extra}" ]]; then + echo "WARNING: no ${kind} JAR found; Maven Central requires one before release" >&2 + continue + fi + cp "${extra}" "${TARGET}/" + echo " ${ARTIFACT_ID}-${VERSION}-${kind}.jar" +done + +echo +echo "Maven repository layout at ${OUTPUT_DIR}" +find "${OUTPUT_DIR}" -type f | sed "s|^${OUTPUT_DIR}/| |" | sort From 6df31e91f2ee2530715c5a305e4face6bfc3fa33 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 27 Aug 2026 16:35:20 -0500 Subject: [PATCH 07/31] Build every classifier and gather them, following cuDF's layout java-static-build now runs as a matrix over CUDA major and architecture, producing cuda12, cuda12-arm64, cuda13 and cuda13-arm64, each uploaded as cuopt_java__cu. java-static-gather downloads them and assembles one cuopt_java_maven_repo artifact, which is what a publishing workflow consumes. Standardized on cuDF's conventions while doing so: argparse.sh gives the scripts one way to reject a missing or empty flag, the matrix comes from compute-matrix.yaml filtered to one entry per arch and CUDA major, and each classifier directory carries its own POM, sources and javadoc JARs so the gather step can work from those directories alone. CI measured the first classifier at 599 MB, against 405 MB locally: the difference is libcuopt_jni.so growing from 173 MB to 371 MB once every CUDA architecture is built. Four classifiers therefore exceed the 1 GB Maven Central bundle limit together, though each fits individually. Signed-off-by: Ramakrishna Prabhu --- .github/workflows/build.yaml | 56 +++++++++++++++++++--- .github/workflows/pr.yaml | 60 ++++++++++++++++++++---- ci/build_java_static.sh | 15 ++---- java/cuopt/ci/argparse.sh | 29 ++++++++++++ java/cuopt/ci/assemble_maven_repo.sh | 14 ++++-- java/cuopt/ci/build_cuopt_java_jar.sh | 25 +++++++--- java/cuopt/ci/verify_jar_dependencies.sh | 8 +++- 7 files changed, 168 insertions(+), 39 deletions(-) create mode 100755 java/cuopt/ci/argparse.sh diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9508368df2..7c6f3975ee 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -64,9 +64,19 @@ jobs: date: ${{ inputs.date }} sha: ${{ inputs.sha }} script: ci/build_cpp.sh - # Exploratory: builds the self-contained classifier JAR from #1817 and checks it carries - # every native library it needs. Independent of java-build, which links the shared libcuopt. + # Exploratory (#1817): one self-contained classifier JAR per CUDA major and architecture, + # gathered into a single Maven-repository artifact for publishing. + java-static-build-matrix: + permissions: + contents: read + uses: rapidsai/shared-workflows/.github/workflows/compute-matrix.yaml@main + with: + build_type: ${{ inputs.build_type || 'branch' }} + matrix_name: conda-cpp-build + matrix_filter: 'map(. + {CUDA_MAJOR: (.CUDA_VER | split(".") | .[0])}) | unique_by([.ARCH, .CUDA_MAJOR])' + java-static-build: + needs: [build-details, java-static-build-matrix] permissions: actions: read contents: read @@ -75,19 +85,51 @@ jobs: pull-requests: read secrets: inherit # zizmor: ignore[secrets-inherit] uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@main + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.java-static-build-matrix.outputs.matrix) }} with: build_type: ${{ inputs.build_type || 'branch' }} branch: ${{ inputs.branch }} date: ${{ inputs.date }} sha: ${{ inputs.sha }} node_type: "cpu16" - arch: "amd64" + arch: ${{ matrix.ARCH }} container_image: "rapidsai/ci-conda:26.10-latest" script: "ci/build_java_static.sh" - # One artifact in Maven repository layout, which is the form a publishing workflow - # consumes. See rapidsai/build-infra#379. - artifact-name: "cuopt_java_maven_repo" - file_to_upload: "java/cuopt/maven-repo/" + artifact-name: "cuopt_java_${{ matrix.ARCH }}_cu${{ matrix.CUDA_MAJOR }}" + file_to_upload: "java/cuopt/classifier-jars/" + + # Combines every classifier into one Maven-repository-layout artifact, which is the form a + # publishing workflow consumes. See rapidsai/build-infra#379. + java-static-gather: + needs: [java-static-build] + runs-on: linux-amd64-cpu4 + permissions: + contents: read + steps: + - name: Checkout code repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ inputs.sha }} + persist-credentials: false + - name: Download per-classifier JAR artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: cuopt_java_* + path: ${{ runner.temp }}/jars + merge-multiple: true + - name: Assemble Maven repository layout + run: | + ./java/cuopt/ci/assemble_maven_repo.sh \ + --jars-dir "${RUNNER_TEMP}/jars" \ + --output-dir "${RUNNER_TEMP}/maven-repo" + - name: Upload combined Maven repository artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: cuopt_java_maven_repo + path: ${{ runner.temp }}/maven-repo + if-no-files-found: error java-build: needs: cpp-build diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 22f3c27cf6..e0f256773a 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -25,6 +25,7 @@ jobs: - conda-cpp-tests - java-build - java-static-build + - java-static-gather - conda-python-build - conda-python-tests - docs-build @@ -483,10 +484,24 @@ jobs: artifact-name: "cuopt_docs" container_image: "rapidsai/ci-conda:26.10-latest" script: "ci/build_docs.sh" - # Exploratory (#1817): builds the self-contained classifier JAR and checks it carries every - # native library it needs. Compiles libcuopt from source, so it does not need conda-cpp-build. - java-static-build: + # Exploratory (#1817): one self-contained classifier JAR per CUDA major and architecture, + # gathered into a single Maven-repository artifact. Compiles libcuopt from source, so it does + # not need conda-cpp-build. + java-static-build-matrix: needs: changed-files + permissions: + contents: read + uses: rapidsai/shared-workflows/.github/workflows/compute-matrix.yaml@main + if: >- + fromJSON(needs.changed-files.outputs.changed_file_groups).test_java || + fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp + with: + build_type: pull-request + matrix_name: conda-cpp-build + matrix_filter: 'map(. + {CUDA_MAJOR: (.CUDA_VER | split(".") | .[0])}) | unique_by([.ARCH, .CUDA_MAJOR])' + + java-static-build: + needs: [java-static-build-matrix, changed-files] permissions: actions: read contents: read @@ -495,19 +510,48 @@ jobs: pull-requests: read secrets: inherit # zizmor: ignore[secrets-inherit] uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@main + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.java-static-build-matrix.outputs.matrix) }} if: >- fromJSON(needs.changed-files.outputs.changed_file_groups).test_java || fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp with: build_type: pull-request node_type: "cpu16" - arch: "amd64" + arch: ${{ matrix.ARCH }} container_image: "rapidsai/ci-conda:26.10-latest" script: "ci/build_java_static.sh" - # One artifact in Maven repository layout, which is the form a publishing workflow - # consumes. See rapidsai/build-infra#379. - artifact-name: "cuopt_java_maven_repo" - file_to_upload: "java/cuopt/maven-repo/" + artifact-name: "cuopt_java_${{ matrix.ARCH }}_cu${{ matrix.CUDA_MAJOR }}" + file_to_upload: "java/cuopt/classifier-jars/" + + java-static-gather: + needs: [java-static-build] + runs-on: linux-amd64-cpu4 + permissions: + contents: read + steps: + - name: Checkout code repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - name: Download per-classifier JAR artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: cuopt_java_* + path: ${{ runner.temp }}/jars + merge-multiple: true + - name: Assemble Maven repository layout + run: | + ./java/cuopt/ci/assemble_maven_repo.sh \ + --jars-dir "${RUNNER_TEMP}/jars" \ + --output-dir "${RUNNER_TEMP}/maven-repo" + - name: Upload combined Maven repository artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: cuopt_java_maven_repo + path: ${{ runner.temp }}/maven-repo + if-no-files-found: error java-build: needs: [conda-cpp-build, changed-files] diff --git a/ci/build_java_static.sh b/ci/build_java_static.sh index 64bd40dc78..e23dbee5d0 100755 --- a/ci/build_java_static.sh +++ b/ci/build_java_static.sh @@ -38,7 +38,6 @@ export CUOPT_PREFIX="${CONDA_PREFIX}" STATIC_BUILD_DIR="${PWD}/cpp/build-static" JNI_BUILD_DIR="${PWD}/java/cuopt/build/native-static" JAR_OUTPUT_DIR="${PWD}/java/cuopt/classifier-jars" -MAVEN_REPO_DIR="${PWD}/java/cuopt/maven-repo" rapids-logger "Building the scoped static libcuopt" BUILD_DIR="${STATIC_BUILD_DIR}" bash java/cuopt/ci/build_static_libcuopt.sh @@ -60,16 +59,12 @@ bash java/cuopt/ci/build_cuopt_java_jar.sh \ # The JAR looking fine on this machine proves nothing: the build environment supplies every # dependency by construction. This resolves them the way a consumer's machine would. rapids-logger "Verifying the JAR is self-contained" -CLASSIFIER_JAR=$(find "${JAR_OUTPUT_DIR}" -name 'cuopt-*.jar' -print -quit) +CLASSIFIER_JAR=$(find "${JAR_OUTPUT_DIR}" -name 'cuopt-*.jar' \ + ! -name '*-sources.jar' ! -name '*-javadoc.jar' -print -quit) bash java/cuopt/ci/verify_jar_dependencies.sh --jar "${CLASSIFIER_JAR}" -# A single artifact in Maven repository layout is what a publishing workflow consumes, so the -# shape is fixed here rather than left to whatever downloads these JARs. -rapids-logger "Assembling the Maven repository layout" -bash java/cuopt/ci/assemble_maven_repo.sh \ - --jars-dir "${JAR_OUTPUT_DIR}" \ - --extra-jars-dir "${PWD}/java/cuopt/target" \ - --output-dir "${MAVEN_REPO_DIR}" - +# The gather job combines the classifier directories from every matrix entry into one Maven +# repository layout; this job uploads its own directory as-is. rapids-logger "Result" du -h "${CLASSIFIER_JAR}" | sed 's/^/ /' +find "${JAR_OUTPUT_DIR}" -type f | sed "s|^${JAR_OUTPUT_DIR}/| |" | sort diff --git a/java/cuopt/ci/argparse.sh b/java/cuopt/ci/argparse.sh new file mode 100755 index 0000000000..8fc52d6e00 --- /dev/null +++ b/java/cuopt/ci/argparse.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Argument-handling helpers shared by the Java CI scripts, so a missing or empty flag fails the +# same way everywhere rather than surfacing later as an unbound variable. + +# require_value — the flag was given but its value is missing. +require_value() { + local flag=$1 + local value=${2:-} + if [[ -z ${value} ]]; then + echo "Error: ${flag} requires a value" >&2 + exit 1 + fi +} + +# require_arg — the flag itself is mandatory. +require_arg() { + local flag=$1 + local value=${2:-} + if [[ -z ${value} ]]; then + echo "Error: ${flag} is required." >&2 + if declare -F print_help > /dev/null; then + print_help >&2 + fi + exit 1 + fi +} diff --git a/java/cuopt/ci/assemble_maven_repo.sh b/java/cuopt/ci/assemble_maven_repo.sh index 4d5e06c2fb..292a213807 100755 --- a/java/cuopt/ci/assemble_maven_repo.sh +++ b/java/cuopt/ci/assemble_maven_repo.sh @@ -16,6 +16,10 @@ set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=java/cuopt/ci/argparse.sh +source "${SCRIPT_DIR}/argparse.sh" + GROUP_PATH="com/nvidia/cuopt" ARTIFACT_ID="cuopt" JARS_DIR="" @@ -41,15 +45,15 @@ EOF while [[ $# -gt 0 ]]; do case $1 in -h | --help) print_help; exit 0 ;; - -j | --jars-dir) JARS_DIR="${2:?--jars-dir needs a value}"; shift 2 ;; - -o | --output-dir) OUTPUT_DIR="${2:?--output-dir needs a value}"; shift 2 ;; - -e | --extra-jars-dir) EXTRA_JARS_DIR="${2:?--extra-jars-dir needs a value}"; shift 2 ;; + -j | --jars-dir) require_value "$1" "${2:-}"; JARS_DIR=$2; shift 2 ;; + -o | --output-dir) require_value "$1" "${2:-}"; OUTPUT_DIR=$2; shift 2 ;; + -e | --extra-jars-dir) require_value "$1" "${2:-}"; EXTRA_JARS_DIR=$2; shift 2 ;; *) echo "Unknown argument: $1" >&2; print_help >&2; exit 2 ;; esac done -: "${JARS_DIR:?--jars-dir is required}" -: "${OUTPUT_DIR:?--output-dir is required}" +require_arg --jars-dir "${JARS_DIR}" +require_arg --output-dir "${OUTPUT_DIR}" if [[ ! -d "${JARS_DIR}" ]]; then echo "jars directory not found: ${JARS_DIR}" >&2 diff --git a/java/cuopt/ci/build_cuopt_java_jar.sh b/java/cuopt/ci/build_cuopt_java_jar.sh index e769fef1cf..2138470324 100755 --- a/java/cuopt/ci/build_cuopt_java_jar.sh +++ b/java/cuopt/ci/build_cuopt_java_jar.sh @@ -11,6 +11,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=java/cuopt/ci/argparse.sh +source "${SCRIPT_DIR}/argparse.sh" MODULE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=java/cuopt/ci/java_classifier.sh source "${SCRIPT_DIR}/java_classifier.sh" @@ -41,17 +43,17 @@ EOF while [[ $# -gt 0 ]]; do case $1 in -h | --help) print_help; exit 0 ;; - -n | --native-lib) NATIVE_LIB="${2:?--native-lib needs a value}"; shift 2 ;; - -c | --cuda-version) CUDA_VERSION="${2:?--cuda-version needs a value}"; shift 2 ;; - -o | --output-dir) OUTPUT_DIR="${2:?--output-dir needs a value}"; shift 2 ;; - -a | --arch) ARCH="${2:?--arch needs a value}"; shift 2 ;; + -n | --native-lib) require_value "$1" "${2:-}"; NATIVE_LIB=$2; shift 2 ;; + -c | --cuda-version) require_value "$1" "${2:-}"; CUDA_VERSION=$2; shift 2 ;; + -o | --output-dir) require_value "$1" "${2:-}"; OUTPUT_DIR=$2; shift 2 ;; + -a | --arch) require_value "$1" "${2:-}"; ARCH=$2; shift 2 ;; *) echo "Unknown argument: $1" >&2; print_help >&2; exit 2 ;; esac done -: "${NATIVE_LIB:?--native-lib is required}" -: "${CUDA_VERSION:?--cuda-version is required}" -: "${OUTPUT_DIR:?--output-dir is required}" +require_arg --native-lib "${NATIVE_LIB}" +require_arg --cuda-version "${CUDA_VERSION}" +require_arg --output-dir "${OUTPUT_DIR}" if [[ ! -f "${NATIVE_LIB}" ]]; then echo "native library not found: ${NATIVE_LIB}" >&2 @@ -102,8 +104,17 @@ VERSION="$(mvn -f "${MODULE_DIR}/pom.xml" -B -q \ -Dexec.executable=echo -Dexec.args='${project.version}' \ --non-recursive exec:exec 2>/dev/null | tail -1)" +# Each classifier directory carries everything Maven Central needs for the artifact, so the +# gather step can work from the classifier directories alone. cp "${MODULE_DIR}/target/cuopt-${VERSION}-${CLASSIFIER}.jar" "${OUTPUT_DIR}/${CLASSIFIER}/" cp "${MODULE_DIR}/pom.xml" "${OUTPUT_DIR}/${CLASSIFIER}/cuopt-${VERSION}.pom" +for kind in sources javadoc; do + if [[ -f "${MODULE_DIR}/target/cuopt-${VERSION}-${kind}.jar" ]]; then + cp "${MODULE_DIR}/target/cuopt-${VERSION}-${kind}.jar" "${OUTPUT_DIR}/${CLASSIFIER}/" + else + echo "WARNING: no ${kind} JAR in ${MODULE_DIR}/target; Maven Central requires one" >&2 + fi +done jar_mb=$(( $(stat -c%s "${OUTPUT_DIR}/${CLASSIFIER}/cuopt-${VERSION}-${CLASSIFIER}.jar") / 1048576 )) echo " wrote ${OUTPUT_DIR}/${CLASSIFIER}/cuopt-${VERSION}-${CLASSIFIER}.jar (${jar_mb} MB)" diff --git a/java/cuopt/ci/verify_jar_dependencies.sh b/java/cuopt/ci/verify_jar_dependencies.sh index 1600715238..ebffb1dda9 100755 --- a/java/cuopt/ci/verify_jar_dependencies.sh +++ b/java/cuopt/ci/verify_jar_dependencies.sh @@ -17,6 +17,10 @@ set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=java/cuopt/ci/argparse.sh +source "${SCRIPT_DIR}/argparse.sh" + JAR="" # Supplied by the CUDA toolkit a consumer installs for the classifier's CUDA major version. @@ -46,12 +50,12 @@ EOF while [[ $# -gt 0 ]]; do case $1 in -h | --help) print_help; exit 0 ;; - -j | --jar) JAR="${2:?--jar needs a value}"; shift 2 ;; + -j | --jar) require_value "$1" "${2:-}"; JAR=$2; shift 2 ;; *) echo "Unknown argument: $1" >&2; print_help >&2; exit 2 ;; esac done -: "${JAR:?--jar is required}" +require_arg --jar "${JAR}" if [[ ! -f "${JAR}" ]]; then echo "JAR not found: ${JAR}" >&2 exit 1 From a4219ed19d039394786069c98113b22947707787 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 27 Aug 2026 17:18:40 -0500 Subject: [PATCH 08/31] Run the whole Java suite against each classifier JAR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testing the packaged JAR with a bespoke smoke test would have covered a fraction of what the suite already covers, so java-static-test runs the suite itself through a packaged-jar-tests profile, following cuDF's ci/test_packaged_java.sh. Main compilation is skipped so the JAR supplies both the classes and the native libraries, and the artifact is fetched with rapids-download-from-github rather than gh, which is what handles the pull-request and nightly cases. Two things this found. NativeTestSupport.assumeNativeLibrary required cuopt.native.dir, which encodes "a native library means a source build". Run against a classifier JAR the suite reported 35 found, 14 passed, 21 aborted — silently skipping every native test, in the configuration where they matter most. It now accepts either route. PackagedJarOriginCheck asserts the classes and the embedded library really came from a JAR, because a stray target/classes on the classpath would shadow it and the run would pass while testing the wrong thing. Confirmed it fails when cuopt.native.dir is set to bypass the JAR. It matches none of surefire's default name patterns, so the profile names it explicitly. Against a classifier JAR: 38/38. From source, unchanged at 35/35, with the origin check excluded. Signed-off-by: Ramakrishna Prabhu --- .github/workflows/build.yaml | 25 ++++++ .github/workflows/pr.yaml | 23 ++++++ ci/test_java_static.sh | 76 +++++++++++++++++++ java/cuopt/ci/java_classifier.sh | 14 ++++ java/cuopt/pom.xml | 56 ++++++++++++++ .../NativeTestSupport.java | 27 ++++++- .../PackagedJarOriginCheck.java | 51 +++++++++++++ 7 files changed, 269 insertions(+), 3 deletions(-) create mode 100755 ci/test_java_static.sh create mode 100644 java/cuopt/src/test/java/com/nvidia/cuopt/mathematicaloptimization/PackagedJarOriginCheck.java diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 7c6f3975ee..684bbc71b3 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -102,6 +102,31 @@ jobs: # Combines every classifier into one Maven-repository-layout artifact, which is the form a # publishing workflow consumes. See rapidsai/build-infra#379. + # Runs the full Java suite against each classifier JAR on a GPU, with no libcuopt installed, + # so a JAR that loads but computes wrong answers fails here rather than at a user. + java-static-test: + needs: [java-static-build, java-static-build-matrix] + permissions: + actions: read + contents: read + id-token: write + packages: read + pull-requests: read + secrets: inherit # zizmor: ignore[secrets-inherit] + uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@main + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.java-static-build-matrix.outputs.matrix) }} + with: + build_type: ${{ inputs.build_type || 'branch' }} + branch: ${{ inputs.branch }} + date: ${{ inputs.date }} + sha: ${{ inputs.sha }} + node_type: "gpu-l4-latest-1" + arch: ${{ matrix.ARCH }} + container_image: "rapidsai/ci-conda:26.10-latest" + script: "ci/test_java_static.sh" + java-static-gather: needs: [java-static-build] runs-on: linux-amd64-cpu4 diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index e0f256773a..36027119da 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -25,6 +25,7 @@ jobs: - conda-cpp-tests - java-build - java-static-build + - java-static-test - java-static-gather - conda-python-build - conda-python-tests @@ -525,6 +526,28 @@ jobs: artifact-name: "cuopt_java_${{ matrix.ARCH }}_cu${{ matrix.CUDA_MAJOR }}" file_to_upload: "java/cuopt/classifier-jars/" + # Runs the full Java suite against each classifier JAR on a GPU, with no libcuopt installed, + # so a JAR that loads but computes wrong answers fails here rather than at a user. + java-static-test: + needs: [java-static-build, java-static-build-matrix] + permissions: + actions: read + contents: read + id-token: write + packages: read + pull-requests: read + secrets: inherit # zizmor: ignore[secrets-inherit] + uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@main + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.java-static-build-matrix.outputs.matrix) }} + with: + build_type: pull-request + node_type: "gpu-l4-latest-1" + arch: ${{ matrix.ARCH }} + container_image: "rapidsai/ci-conda:26.10-latest" + script: "ci/test_java_static.sh" + java-static-gather: needs: [java-static-build] runs-on: linux-amd64-cpu4 diff --git a/ci/test_java_static.sh b/ci/test_java_static.sh new file mode 100755 index 0000000000..02d499250e --- /dev/null +++ b/ci/test_java_static.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Runs the Java test suite against an already-packaged classifier JAR, on a GPU, with no +# libcuopt installed. See #1817. +# +# ci/build_java_static.sh checks the JAR's dependencies statically; this is the other half, +# that the libraries it carries actually load and produce correct answers. +# +# Activates -Ppackaged-jar-tests so main compilation is skipped and the JAR supplies the classes +# and the native libraries. PackagedJarOriginCheck then asserts that is genuinely where they came +# from, so a stray target/classes cannot make this pass while testing the wrong thing. +# +# CUOPT_JAVA_JAR may be set to a classifier JAR to skip the download and test it directly. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=java/cuopt/ci/java_classifier.sh +. "${REPO_ROOT}/java/cuopt/ci/java_classifier.sh" + +if [[ -e /opt/conda/etc/profile.d/conda.sh ]]; then + . /opt/conda/etc/profile.d/conda.sh +fi + +if [[ -z "${CUOPT_JAVA_JAR:-}" ]]; then + case "$(arch)" in + x86_64) JOB_ARCH=amd64 ;; + aarch64) JOB_ARCH=arm64 ;; + *) echo "unsupported architecture $(arch)" >&2; exit 1 ;; + esac + ARTIFACT="cuopt_java_${JOB_ARCH}_cu${RAPIDS_CUDA_VERSION%%.*}" + rapids-logger "Downloading ${ARTIFACT}" + JAVA_PKG="$(rapids-download-from-github "${ARTIFACT}")" + CUOPT_JAVA_JAR="$(cuopt_java_resolve_artifact_jar "${JAVA_PKG}")" +fi +rapids-logger "Testing $(basename "${CUOPT_JAVA_JAR}")" + +# A JDK, Maven and the CUDA runtime only. Installing libcuopt would defeat the test, since the +# JAR is supposed to carry its own copy. +rapids-logger "Creating a consumer-like environment" +ENV_YAML_DIR=$(mktemp -d) +cat > "${ENV_YAML_DIR}/env.yaml" << EOF +name: java_static_test +channels: + - conda-forge +dependencies: + - openjdk=11.* + - maven + - cuda-version=${RAPIDS_CUDA_VERSION%.*} + - libcublas + - libcusparse +EOF +rapids-mamba-retry env create --yes -f "${ENV_YAML_DIR}/env.yaml" -n java_static_test + +set +u +conda activate java_static_test +set -u + +if [[ -e "${CONDA_PREFIX}/lib/libcuopt.so" ]]; then + echo "ERROR: libcuopt.so is present in the test environment, so passing here would not show" >&2 + echo " that the JAR is self-contained." >&2 + exit 1 +fi + +rapids-print-env +nvidia-smi + +rapids-logger "Running the suite against the packaged JAR" +mvn -B -f "${REPO_ROOT}/java/cuopt/pom.xml" test \ + -Ppackaged-jar-tests \ + "-Dcuopt.jar.path=${CUOPT_JAVA_JAR}" + +rapids-logger "Classifier JAR verified end to end" diff --git a/java/cuopt/ci/java_classifier.sh b/java/cuopt/ci/java_classifier.sh index 9197c266fa..7537948ebd 100755 --- a/java/cuopt/ci/java_classifier.sh +++ b/java/cuopt/ci/java_classifier.sh @@ -39,3 +39,17 @@ cuopt_java_native_resource_dir() { ;; esac } + +# Finds the classifier JAR inside a downloaded build artifact, ignoring the sources and javadoc +# JARs that sit beside it. +cuopt_java_resolve_artifact_jar() { + local artifact_dir="${1:?missing artifact directory}" + local jar + jar="$(find "${artifact_dir}" -name 'cuopt-*.jar' \ + ! -name '*-sources.jar' ! -name '*-javadoc.jar' -print -quit)" + if [[ -z "${jar}" ]]; then + echo "no classifier JAR found under ${artifact_dir}" >&2 + return 1 + fi + printf '%s\n' "${jar}" +} diff --git a/java/cuopt/pom.xml b/java/cuopt/pom.xml index 35a7a48227..f6e8c66f9c 100644 --- a/java/cuopt/pom.xml +++ b/java/cuopt/pom.xml @@ -49,6 +49,9 @@ SPDX-License-Identifier: Apache-2.0 ${project.basedir}/src/main/no-native + + **/PackagedJarOriginCheck.java @@ -67,6 +70,16 @@ SPDX-License-Identifier: Apache-2.0 + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + ${cuopt.surefire.excludes} + + + org.apache.maven.plugins maven-jar-plugin @@ -168,4 +181,47 @@ SPDX-License-Identifier: Apache-2.0 + + + + + packaged-jar-tests + + true + **/NothingIsExcludedHere.java + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + + **/*Test.java + **/PackagedJarOriginCheck.java + + + + + + + + com.nvidia.cuopt + cuopt-packaged + ${project.version} + system + ${cuopt.jar.path} + + + + diff --git a/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicaloptimization/NativeTestSupport.java b/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicaloptimization/NativeTestSupport.java index 541f413e7f..ab8ecff965 100644 --- a/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicaloptimization/NativeTestSupport.java +++ b/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicaloptimization/NativeTestSupport.java @@ -13,12 +13,33 @@ final class NativeTestSupport { private NativeTestSupport() {} + /** + * Skips when there is no native library to load, by either route the loader accepts: a + * directory named by {@code cuopt.native.dir} for a source build, or a copy embedded in a + * classifier JAR on the classpath. Requiring the property alone would silently skip the whole + * native suite when it runs against a JAR, which is where it matters most. + */ static void assumeNativeLibrary() { + String fileName = System.mapLibraryName("cuopt_jni"); String nativeDir = System.getProperty("cuopt.native.dir"); - Assumptions.assumeTrue(nativeDir != null && !nativeDir.isBlank(), "cuopt.native.dir is unset"); + if (nativeDir != null && !nativeDir.isBlank()) { + Assumptions.assumeTrue( + Files.exists(Path.of(nativeDir, fileName)), "libcuopt_jni is not built"); + return; + } Assumptions.assumeTrue( - Files.exists(Path.of(nativeDir, System.mapLibraryName("cuopt_jni"))), - "libcuopt_jni is not built"); + embeddedLibraryPresent(fileName), + "no libcuopt_jni: cuopt.native.dir is unset and no copy is embedded on the classpath"); + } + + private static boolean embeddedLibraryPresent(String fileName) { + try { + String resource = + NativeLibraryLoader.resourcePath(System.getProperty("os.arch", ""), fileName); + return NativeTestSupport.class.getResource(resource) != null; + } catch (IllegalStateException unsupportedArchitecture) { + return false; + } } private static final long NVIDIA_SMI_TIMEOUT_SECONDS = 30; diff --git a/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicaloptimization/PackagedJarOriginCheck.java b/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicaloptimization/PackagedJarOriginCheck.java new file mode 100644 index 0000000000..40da9005db --- /dev/null +++ b/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicaloptimization/PackagedJarOriginCheck.java @@ -0,0 +1,51 @@ +/* + * 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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URL; +import org.junit.jupiter.api.Test; + +/** + * Confirms the suite is exercising a packaged classifier JAR rather than classes built from + * source. + * + *

Excluded by default and run only under {@code -Ppackaged-jar-tests}. Without it a stray + * {@code target/classes} on the classpath would shadow the JAR and the run would pass while + * testing the wrong thing entirely — which is the failure this whole job exists to rule out. + */ +final class PackagedJarOriginCheck { + @Test + void classesComeFromAJarRatherThanADirectory() { + URL location = Problem.class.getProtectionDomain().getCodeSource().getLocation(); + assertNotNull(location, "no code source for Problem"); + String path = location.getPath(); + assertTrue( + path.endsWith(".jar"), + "expected Problem to be loaded from a packaged JAR, but it came from " + path); + } + + @Test + void theNativeLibraryIsEmbeddedInThatJar() { + String resource = + NativeLibraryLoader.resourcePath( + System.getProperty("os.arch", ""), System.mapLibraryName("cuopt_jni")); + URL embedded = PackagedJarOriginCheck.class.getResource(resource); + assertNotNull(embedded, "no " + resource + " on the classpath"); + assertTrue( + "jar".equals(embedded.getProtocol()), + "expected the native library to come from a JAR, but it came from " + embedded); + } + + @Test + void noNativeDirectoryOverrideIsInEffect() { + String nativeDir = System.getProperty("cuopt.native.dir"); + assertTrue( + nativeDir == null || nativeDir.isBlank(), + "cuopt.native.dir is set to '" + nativeDir + "', so the JAR's own library was bypassed"); + } +} From a623cc446335c5f950bcd3a6ba2ba94c41093720 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 27 Aug 2026 18:20:27 -0500 Subject: [PATCH 09/31] Add java-static-build-matrix to the pr-builder aggregator rapids-check-pr-job-dependencies requires every job to be a dependency of pr-builder. The build, test and gather jobs were listed but the matrix job that feeds them was not, so the checks job failed. pr-test-summary remains the only job outside the aggregator, which is expected and already ignored. Signed-off-by: Ramakrishna Prabhu --- .github/workflows/pr.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 36027119da..6634509de1 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -24,6 +24,7 @@ jobs: - conda-cpp-build - conda-cpp-tests - java-build + - java-static-build-matrix - java-static-build - java-static-test - java-static-gather From 78822d79f8e7b4bc7bee3d9f2156c5d9269e699b Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 27 Aug 2026 21:24:40 -0500 Subject: [PATCH 10/31] Route the new Maven calls through the retry wrapper All four java-static-test jobs failed resolving maven-source-plugin from Maven Central with a 429. cuopt_mvn exists to retry exactly that, but the packaging and test scripts called mvn directly and so never got it. The version is now read from the POM's update marker instead of by invoking Maven. That removes a network round trip from the packaging step, and avoids capturing the wrapper's merged stderr into the version string. Signed-off-by: Ramakrishna Prabhu --- ci/test_java_static.sh | 5 ++++- java/cuopt/ci/build_cuopt_java_jar.sh | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/ci/test_java_static.sh b/ci/test_java_static.sh index 02d499250e..47182bde59 100755 --- a/ci/test_java_static.sh +++ b/ci/test_java_static.sh @@ -20,6 +20,9 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=java/cuopt/ci/java_classifier.sh . "${REPO_ROOT}/java/cuopt/ci/java_classifier.sh" +# shellcheck source=java/cuopt/scripts/maven.sh +. "${REPO_ROOT}/java/cuopt/scripts/maven.sh" +cuopt_maven_args if [[ -e /opt/conda/etc/profile.d/conda.sh ]]; then . /opt/conda/etc/profile.d/conda.sh @@ -69,7 +72,7 @@ rapids-print-env nvidia-smi rapids-logger "Running the suite against the packaged JAR" -mvn -B -f "${REPO_ROOT}/java/cuopt/pom.xml" test \ +cuopt_mvn -B -f "${REPO_ROOT}/java/cuopt/pom.xml" test \ -Ppackaged-jar-tests \ "-Dcuopt.jar.path=${CUOPT_JAVA_JAR}" diff --git a/java/cuopt/ci/build_cuopt_java_jar.sh b/java/cuopt/ci/build_cuopt_java_jar.sh index 2138470324..f74ab93e8d 100755 --- a/java/cuopt/ci/build_cuopt_java_jar.sh +++ b/java/cuopt/ci/build_cuopt_java_jar.sh @@ -13,6 +13,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=java/cuopt/ci/argparse.sh source "${SCRIPT_DIR}/argparse.sh" +# shellcheck source=java/cuopt/scripts/maven.sh +source "${SCRIPT_DIR}/../scripts/maven.sh" +cuopt_maven_args MODULE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=java/cuopt/ci/java_classifier.sh source "${SCRIPT_DIR}/java_classifier.sh" @@ -94,15 +97,20 @@ for companion in librmm.so librapids_logger.so libtbb.so.12 libnccl.so.2 libcuds done mkdir -p "${OUTPUT_DIR}/${CLASSIFIER}" -mvn -f "${MODULE_DIR}/pom.xml" -B \ +cuopt_mvn -f "${MODULE_DIR}/pom.xml" -B \ -DskipTests \ -Dcuopt.jar.classifier="${CLASSIFIER}" \ -Dcuopt.native.resources="${STAGING}" \ package -VERSION="$(mvn -f "${MODULE_DIR}/pom.xml" -B -q \ - -Dexec.executable=echo -Dexec.args='${project.version}' \ - --non-recursive exec:exec 2>/dev/null | tail -1)" +# Read straight from the POM rather than asking Maven: this needs no network, and +# ci/release/update-version.sh keeps the marker in step with the version. +VERSION="$(sed -n 's/.*VERSION_UPDATE_MARKER_START-->\([^<]*\)<\/version>.*/\1/p' \ + "${MODULE_DIR}/pom.xml")" +if [[ -z "${VERSION}" ]]; then + echo "could not read the version from ${MODULE_DIR}/pom.xml" >&2 + exit 1 +fi # Each classifier directory carries everything Maven Central needs for the artifact, so the # gather step can work from the classifier directories alone. From 0c0fceb3b76d6e47a3ff2b270b16afe195a7ab73 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 28 Aug 2026 11:10:10 -0500 Subject: [PATCH 11/31] 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 74325ec67210bf33a64c30e9612de4363f4654db Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 28 Aug 2026 11:21:24 -0500 Subject: [PATCH 12/31] 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 0bfbf4dc29ac7a521a2e5da3b5df66d27dde2f6a Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 28 Aug 2026 16:48:06 -0500 Subject: [PATCH 13/31] 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 b74fce4ef4..eccf0cc00f 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -293,11 +293,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 9558f83b5596281efbe0ae1c5aef9bae70e26774 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 28 Aug 2026 16:56:07 -0500 Subject: [PATCH 14/31] 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 1ee37cb664aa5ebadb0d3cc91e7774af63fa9df7 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 28 Aug 2026 16:58:50 -0500 Subject: [PATCH 15/31] 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; } - From 072308eea24fbf43f72529c7f66311c154224500 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Mon, 31 Aug 2026 10:06:39 -0500 Subject: [PATCH 16/31] Pin PSLP past v0.0.11 to the merged fix commit instead of patching PSLP's fix for the stray infeasible-message printf (github.com/dance858/PSLP/pull/55) merged upstream but hasn't shipped in a tagged release yet. Point GIT_TAG at the merge commit directly and drop the local PATCH_COMMAND workaround; move this to a real tag once one is cut. Co-Authored-By: Claude Sonnet 5 --- cpp/CMakeLists.txt | 18 ++++++++---------- ...espect_verbose_for_infeasible_message.patch | 15 --------------- 2 files changed, 8 insertions(+), 25 deletions(-) delete mode 100644 cpp/cmake/patches/pslp/respect_verbose_for_infeasible_message.patch diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index eccf0cc00f..bfebd06f2a 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -294,20 +294,18 @@ 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. +# 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" 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 deleted file mode 100644 index 784ab23370..0000000000 --- a/cpp/cmake/patches/pslp/respect_verbose_for_infeasible_message.patch +++ /dev/null @@ -1,15 +0,0 @@ -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,6 +720,9 @@ 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 59cc1448a252ec94fb38e12c40abb8568031a9e1 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Mon, 31 Aug 2026 10:08:07 -0500 Subject: [PATCH 17/31] Merge the duplicate maven-surefire-plugin declaration into one pom.xml had two separate blocks for maven-surefire-plugin in the same list -- one from main (useModulePath, cuopt.native.dir) and one added for the classifier-JAR packaging (excludes). Maven warned about this on every build: 'build.plugins.plugin.(groupId:artifactId)' must be unique but found duplicate declaration of plugin org.apache.maven.plugins:maven-surefire-plugin With two declarations, Maven's merge behavior for the resulting effective configuration is order-dependent, which is exactly the kind of thing worth not leaving to chance in the same plugin whose fork behavior we're relying on to not corrupt stdout. Merged into a single block with all three configuration elements; the packaged-jar-tests profile's own surefire-plugin override is unaffected, since a profile augmenting the main build's plugin config is normal Maven merging, not a duplicate. Verified: `mvn validate` no longer emits the duplicate-plugin warning, and the full local test suite (35 tests) still passes with the fix applied. Co-Authored-By: Claude Sonnet 5 --- java/cuopt/pom.xml | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/java/cuopt/pom.xml b/java/cuopt/pom.xml index f6e8c66f9c..10bde6fa85 100644 --- a/java/cuopt/pom.xml +++ b/java/cuopt/pom.xml @@ -70,16 +70,6 @@ SPDX-License-Identifier: Apache-2.0 - - org.apache.maven.plugins - maven-surefire-plugin - 3.5.2 - - - ${cuopt.surefire.excludes} - - - org.apache.maven.plugins maven-jar-plugin @@ -177,6 +167,9 @@ SPDX-License-Identifier: Apache-2.0 ${cuopt.native.dir} + + ${cuopt.surefire.excludes} + From 6f37f3d3f84a1e023cc384b0a2d529b389209315 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Mon, 31 Aug 2026 13:30:20 -0500 Subject: [PATCH 18/31] Add console-log-sink diagnostics; temporarily skip non-Java CI for faster iteration The java-static-test failures show cuOpt's raw solver output reaching native stdout despite the NativeLogSink fix, corrupting Surefire's forked-JVM channel -- but the same build passes cleanly when run locally, so add fprintf diagnostics around set_console_log_callback, console_log_callback and init_logger_t's sink selection to see which branch actually fires in the CI environment. Also disable every pr.yaml job outside the java-static-* path (if: false) so this iterates on java-static-test alone instead of waiting on the full matrix. Revert before merge. --- .github/workflows/pr.yaml | 50 +++++++++++++----------- cpp/src/utilities/logger.cpp | 27 +++++++++++++ java/cuopt/src/main/native/cuopt_jni.cpp | 40 +++++++++++++++++-- 3 files changed, 92 insertions(+), 25 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 6634509de1..9762c1b00d 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -384,11 +384,8 @@ jobs: conda-cpp-build: needs: [build-details, checks, compute-matrix-filters, changed-files] # Consumed by C++, Java, Python, and docs jobs. - if: >- - fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp || - fromJSON(needs.changed-files.outputs.changed_file_groups).test_java || - fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_conda || - fromJSON(needs.changed-files.outputs.changed_file_groups).build_docs + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false permissions: actions: read contents: read @@ -410,7 +407,8 @@ jobs: packages: read pull-requests: read uses: rapidsai/shared-workflows/.github/workflows/conda-cpp-tests.yaml@main - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false with: build_type: pull-request script: ci/test_cpp.sh @@ -430,15 +428,15 @@ jobs: packages: read pull-requests: read uses: ./.github/workflows/multi_gpu_cpp_test.yaml - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false with: build_type: pull-request conda-python-build: needs: [build-details, conda-cpp-build, changed-files] # Consumed by conda-python-tests and docs-build. - if: >- - fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_conda || - fromJSON(needs.changed-files.outputs.changed_file_groups).build_docs + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false permissions: actions: read contents: read @@ -462,7 +460,8 @@ jobs: packages: read pull-requests: read uses: rapidsai/shared-workflows/.github/workflows/conda-python-tests.yaml@main - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_conda + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false with: build_type: pull-request run_codecov: false @@ -477,7 +476,8 @@ jobs: pull-requests: read secrets: inherit # zizmor: ignore[secrets-inherit] uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@main - if: fromJSON(needs.changed-files.outputs.changed_file_groups).build_docs + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false with: build_type: pull-request node_type: "gpu-l4-latest-1" @@ -587,9 +587,8 @@ jobs: pull-requests: read secrets: inherit # zizmor: ignore[secrets-inherit] uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@main - if: >- - fromJSON(needs.changed-files.outputs.changed_file_groups).test_java || - fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false with: build_type: pull-request node_type: "gpu-l4-latest-1" @@ -600,7 +599,8 @@ jobs: file_to_upload: "java/cuopt/target/" wheel-build-libcuopt: needs: [build-details, compute-matrix-filters, changed-files] - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false permissions: actions: read contents: read @@ -619,7 +619,8 @@ jobs: script: ci/build_wheel_libcuopt.sh wheel-build-cuopt: needs: [build-details, wheel-build-libcuopt, compute-matrix-filters, changed-files] - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false permissions: actions: read contents: read @@ -645,7 +646,8 @@ jobs: packages: read pull-requests: read uses: rapidsai/shared-workflows/.github/workflows/wheels-test.yaml@main - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false with: build_type: pull-request script: ci/test_wheel_cuopt.sh @@ -658,7 +660,8 @@ jobs: script-env-secret-3-value: ${{ secrets.CUOPT_AWS_SECRET_ACCESS_KEY }} wheel-build-cuopt-server: needs: [build-details, checks, compute-matrix-filters, changed-files] - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false permissions: actions: read contents: read @@ -678,7 +681,8 @@ jobs: matrix_filter: ${{ needs.compute-matrix-filters.outputs.cuopt_server_filter }} wheel-build-cuopt-sh-client: needs: [build-details, compute-matrix-filters, changed-files] - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false permissions: actions: read contents: read @@ -706,7 +710,8 @@ jobs: packages: read pull-requests: read uses: rapidsai/shared-workflows/.github/workflows/wheels-test.yaml@main - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false with: build_type: pull-request script: ci/test_wheel_cuopt_server.sh @@ -728,7 +733,8 @@ jobs: pull-requests: read secrets: inherit # zizmor: ignore[secrets-inherit] uses: ./.github/workflows/self_hosted_service_test.yaml - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false with: build_type: pull-request script: ci/test_self_hosted_service.sh diff --git a/cpp/src/utilities/logger.cpp b/cpp/src/utilities/logger.cpp index 7b2170db34..77e065a7cc 100644 --- a/cpp/src/utilities/logger.cpp +++ b/cpp/src/utilities/logger.cpp @@ -8,6 +8,9 @@ #include #include +#include +#include + namespace cuopt { struct buffered_entry { @@ -66,11 +69,23 @@ void set_console_log_callback(log_console_callback_t callback) { std::lock_guard lock(g_console_callback_mutex); g_console_callback = callback; + fprintf(stderr, + "[cuopt-logger-debug] set_console_log_callback: &g_console_callback=%p callback=%p " + "(thread=%zu)\n", + static_cast(&g_console_callback), + reinterpret_cast(callback), + std::hash{}(std::this_thread::get_id())); } static log_console_callback_t console_log_callback() { std::lock_guard lock(g_console_callback_mutex); + fprintf(stderr, + "[cuopt-logger-debug] console_log_callback(getter): &g_console_callback=%p value=%p " + "(thread=%zu)\n", + static_cast(&g_console_callback), + reinterpret_cast(g_console_callback), + std::hash{}(std::this_thread::get_id())); return g_console_callback; } @@ -168,10 +183,18 @@ static std::mutex g_guard_mutex; init_logger_t::init_logger_t(std::string log_file, bool log_to_console) { std::lock_guard lock(g_guard_mutex); + fprintf(stderr, + "[cuopt-logger-debug] init_logger_t: constructing (thread=%zu, log_to_console=%d)\n", + std::hash{}(std::this_thread::get_id()), + log_to_console); auto existing_guard = g_active_guard.lock(); if (existing_guard) { // Reuse existing configuration, just hold a reference to keep it alive + fprintf(stderr, + "[cuopt-logger-debug] init_logger_t: reusing existing guard, sinks NOT " + "reconfigured (callback registered=%d)\n", + console_log_callback() != nullptr); guard_ = existing_guard; return; } @@ -181,9 +204,13 @@ init_logger_t::init_logger_t(std::string log_file, bool log_to_console) // re-initialize sinks if (log_to_console) { if (auto callback = console_log_callback(); callback != nullptr) { + fprintf(stderr, "[cuopt-logger-debug] init_logger_t: installing callback_sink_mt\n"); cuopt::default_logger().sinks().push_back( std::make_shared(callback)); } else { + fprintf(stderr, + "[cuopt-logger-debug] init_logger_t: no callback registered yet, installing raw " + "std::cout ostream_sink_mt\n"); cuopt::default_logger().sinks().push_back( std::make_shared(std::cout)); } diff --git a/java/cuopt/src/main/native/cuopt_jni.cpp b/java/cuopt/src/main/native/cuopt_jni.cpp index 439ac63cd1..5e6eab461d 100644 --- a/java/cuopt/src/main/native/cuopt_jni.cpp +++ b/java/cuopt/src/main/native/cuopt_jni.cpp @@ -4,7 +4,9 @@ */ #include +#include #include +#include #include #include #include @@ -388,19 +390,43 @@ std::once_flag g_log_sink_once; // 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; } + fprintf(stderr, + "[cuopt-jni-debug] console_log_callback: entered (thread=%zu, g_log_sink_class=%p, " + "g_log_sink_method=%p)\n", + std::hash{}(std::this_thread::get_id()), + static_cast(g_log_sink_class), + static_cast(g_log_sink_method)); + if (g_log_sink_class == nullptr || g_log_sink_method == nullptr) { + fprintf(stderr, + "[cuopt-jni-debug] console_log_callback: invoked but sink not registered, message=%s", + message); + return; + } bool detach = false; JNIEnv* env = get_callback_env(detach); - if (env == nullptr) { return; } + if (env == nullptr) { + fprintf(stderr, + "[cuopt-jni-debug] console_log_callback: get_callback_env returned nullptr " + "(thread=%zu)\n", + std::hash{}(std::this_thread::get_id())); + 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(); } + if (env->ExceptionCheck() == JNI_TRUE) { + fprintf(stderr, + "[cuopt-jni-debug] console_log_callback: CallStaticVoidMethod raised a pending " + "exception, clearing it\n"); + env->ExceptionClear(); + } env->DeleteLocalRef(line); + } else { + fprintf(stderr, "[cuopt-jni-debug] console_log_callback: NewStringUTF returned null\n"); } if (detach) { g_jvm->DetachCurrentThread(); } @@ -412,13 +438,19 @@ void console_log_callback(int /* level */, const char* message) void register_console_log_sink(JNIEnv* env) { std::call_once(g_log_sink_once, [env]() { + fprintf(stderr, "[cuopt-jni-debug] register_console_log_sink: entering call_once\n"); jclass local_cls = env->FindClass("com/nvidia/cuopt/mathematicaloptimization/NativeLogSink"); if (local_cls == nullptr) { + fprintf(stderr, + "[cuopt-jni-debug] register_console_log_sink: FindClass(NativeLogSink) failed\n"); env->ExceptionClear(); return; } jmethodID method = env->GetStaticMethodID(local_cls, "onLogLine", "(Ljava/lang/String;)V"); if (method == nullptr) { + fprintf(stderr, + "[cuopt-jni-debug] register_console_log_sink: GetStaticMethodID(onLogLine) " + "failed\n"); env->ExceptionClear(); env->DeleteLocalRef(local_cls); return; @@ -427,6 +459,8 @@ void register_console_log_sink(JNIEnv* env) g_log_sink_method = method; env->DeleteLocalRef(local_cls); cuopt::set_console_log_callback(&console_log_callback); + fprintf(stderr, + "[cuopt-jni-debug] register_console_log_sink: callback registered successfully\n"); }); } From 4bc2d27e09bed3d73bc6dba814a79d571dd76091 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Mon, 31 Aug 2026 17:08:51 -0500 Subject: [PATCH 19/31] Package cuDSS's OpenMP threading-layer plugin; dump Surefire fork-crash diagnostics inline Root cause of java-static-test's flaky Surefire "Corrupted channel" crash, found by reproducing it locally against a self-contained classifier JAR inside the real rapidsai/ci-conda container (a plain -Dcuopt.native.dir run never hit it, because that path's broader LD_LIBRARY_PATH happened to expose the missing library anyway): libcudss.so.0 dlopen()s a separate OpenMP threading backend, libcudss_mtlayer_gomp.so.0, from cudssSetThreadingLayer at runtime. That companion was missing from both NativeLibraryLoader's embedded- resource list and build_cuopt_java_jar.sh's packaging step, so in a genuinely consumer-like environment (no libcuopt, no broader LD_LIBRARY_PATH -- exactly what java-static-test runs) the call fails and cuDSS writes its own failure message straight to the process's native stdout: FAILED: CUDSS call ended unsuccessfully with status = 3, details: "cudssSetThreadingLayer" That's a raw write cuOpt's own logger never sees, so no amount of NativeLogSink/PSLP fixing (see #1825) could catch it -- three independent sources were writing to the same stream. Verified with 3 clean runs against the container repro after packaging the missing library. Also dump target/surefire-reports/*.dumpstream and hs_err_pid*.log inline in ci/test_java_static.sh on failure, and upload surefire-reports as a job artifact: this exact diagnosis depended on reading the dumpstream file's contents, which neither the console log nor any uploaded artifact previously exposed. --- .github/workflows/pr.yaml | 6 ++++++ ci/test_java_static.sh | 18 ++++++++++++++++-- java/cuopt/ci/build_cuopt_java_jar.sh | 5 ++++- .../NativeLibraryLoader.java | 11 +++++++++-- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 9762c1b00d..fb9d38385a 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -548,6 +548,12 @@ jobs: arch: ${{ matrix.ARCH }} container_image: "rapidsai/ci-conda:26.10-latest" script: "ci/test_java_static.sh" + # Surefire fork-crash diagnostics (dumpstream/hs_err files) are also printed inline by + # the script on failure, but keep the raw reports downloadable too -- the JVM sometimes + # crashes without a clean dumpstream, and per-matrix-entry artifacts survive independently + # of how much of the console log GitHub keeps. + artifact-name: "cuopt_java_static_test_${{ matrix.ARCH }}_cu${{ matrix.CUDA_MAJOR }}" + file_to_upload: "java/cuopt/target/surefire-reports/" java-static-gather: needs: [java-static-build] diff --git a/ci/test_java_static.sh b/ci/test_java_static.sh index 47182bde59..1b07818ee6 100755 --- a/ci/test_java_static.sh +++ b/ci/test_java_static.sh @@ -72,8 +72,22 @@ rapids-print-env nvidia-smi rapids-logger "Running the suite against the packaged JAR" -cuopt_mvn -B -f "${REPO_ROOT}/java/cuopt/pom.xml" test \ +if ! cuopt_mvn -B -f "${REPO_ROOT}/java/cuopt/pom.xml" test \ -Ppackaged-jar-tests \ - "-Dcuopt.jar.path=${CUOPT_JAVA_JAR}" + "-Dcuopt.jar.path=${CUOPT_JAVA_JAR}"; then + # Surefire's forked-JVM crash diagnostics (e.g. a raw native write to stdout corrupting its + # fork-communication channel) land in target/surefire-reports/*.dumpstream and any + # hs_err_pid*.log a real JVM crash leaves behind. Neither is printed to the console or + # uploaded as an artifact by this job, so a failure here is otherwise a dead end without + # reproducing it locally. Print them inline instead. + rapids-logger "Test failure -- dumping Surefire fork-crash diagnostics" + find "${REPO_ROOT}/java/cuopt/target/surefire-reports" -type f \ + \( -name '*.dumpstream' -o -name 'hs_err_pid*.log' \) -print0 2>/dev/null | + while IFS= read -r -d '' f; do + echo "----- ${f} -----" + cat "${f}" + done + exit 1 +fi rapids-logger "Classifier JAR verified end to end" diff --git a/java/cuopt/ci/build_cuopt_java_jar.sh b/java/cuopt/ci/build_cuopt_java_jar.sh index f74ab93e8d..061fc81ddf 100755 --- a/java/cuopt/ci/build_cuopt_java_jar.sh +++ b/java/cuopt/ci/build_cuopt_java_jar.sh @@ -85,7 +85,10 @@ echo " native library -> ${RESOURCE_DIR}/libcuopt_jni.so" # rmm and rapids_logger define the exception types cuOpt throws and have no static build, so # they ship beside the JNI library, which finds them through its $ORIGIN RPATH. -for companion in librmm.so librapids_logger.so libtbb.so.12 libnccl.so.2 libcudss.so.0; do +# libcudss_mtlayer_gomp.so.0 is cuDSS's OpenMP threading backend: cudssSetThreadingLayer +# dlopen()s it at runtime. Without it that call fails and cuDSS writes the failure straight to +# the process's native stdout, corrupting Maven Surefire's forked-JVM protocol. +for companion in librmm.so librapids_logger.so libtbb.so.12 libnccl.so.2 libcudss.so.0 libcudss_mtlayer_gomp.so.0; do companion_path="${CUOPT_PREFIX:-}/lib/${companion}" if [[ ! -f "${companion_path}" ]]; then echo "ERROR: ${companion} not found at ${companion_path}; set CUOPT_PREFIX" >&2 diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java index 1d92a2a497..31561972cb 100644 --- a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java @@ -24,8 +24,15 @@ final class NativeLibraryLoader { private static final String LIBRARY_NAME = "cuopt_jni"; - /** rmm, rapids_logger and TBB have no static build, so they travel beside the JNI library. */ - private static final String[] COMPANION_LIBRARIES = {"librmm.so", "librapids_logger.so", "libtbb.so.12", "libnccl.so.2", "libcudss.so.0"}; + /** + * rmm, rapids_logger and TBB have no static build, so they travel beside the JNI library. + * libcudss_mtlayer_gomp.so.0 is cuDSS's OpenMP threading backend, which cudssSetThreadingLayer + * dlopen()s at runtime rather than linking directly; without it that call fails and cuDSS + * writes the failure straight to the process's native stdout, corrupting Maven Surefire's + * forked-JVM protocol exactly like the raw writes NativeLogSink was built to intercept -- but + * from a source outside cuopt's own logger entirely, so no logging fix here can catch it. + */ + private static final String[] COMPANION_LIBRARIES = {"librmm.so", "librapids_logger.so", "libtbb.so.12", "libnccl.so.2", "libcudss.so.0", "libcudss_mtlayer_gomp.so.0"}; private NativeLibraryLoader() {} From d468b48b4c06cd30f25a2dfdd456783c5b69a633 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Mon, 31 Aug 2026 17:48:46 -0500 Subject: [PATCH 20/31] Remove debug instrumentation and pr.yaml CI trimming used to root-cause the crash Both were temporary: the fprintf diagnostics in logger.cpp/cuopt_jni.cpp confirmed NativeLogSink's callback routing works correctly on every solve (never falls back to raw std::cout, never skips reconfiguring an existing guard), which ruled out this PR's own logging path as the remaining cause of java-static-test's Surefire "Corrupted channel" crash. The actual cause -- cuDSS's missing OpenMP threading-layer companion library -- is fixed independently in 4bc2d27e and verified clean against a local reproduction of the real CI container three times, with and without this debug code. pr.yaml's non-java if:false gates were only there to keep iteration on java-static-test fast; restored to the normal changed-files-gated conditions, keeping the surefire-reports artifact upload added alongside the real fix. --- .github/workflows/pr.yaml | 50 +++++++++++------------- cpp/src/utilities/logger.cpp | 27 ------------- java/cuopt/src/main/native/cuopt_jni.cpp | 40 ++----------------- 3 files changed, 25 insertions(+), 92 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index fb9d38385a..7baf3597bc 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -384,8 +384,11 @@ jobs: conda-cpp-build: needs: [build-details, checks, compute-matrix-filters, changed-files] # Consumed by C++, Java, Python, and docs jobs. - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: >- + fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp || + fromJSON(needs.changed-files.outputs.changed_file_groups).test_java || + fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_conda || + fromJSON(needs.changed-files.outputs.changed_file_groups).build_docs permissions: actions: read contents: read @@ -407,8 +410,7 @@ jobs: packages: read pull-requests: read uses: rapidsai/shared-workflows/.github/workflows/conda-cpp-tests.yaml@main - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp with: build_type: pull-request script: ci/test_cpp.sh @@ -428,15 +430,15 @@ jobs: packages: read pull-requests: read uses: ./.github/workflows/multi_gpu_cpp_test.yaml - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp with: build_type: pull-request conda-python-build: needs: [build-details, conda-cpp-build, changed-files] # Consumed by conda-python-tests and docs-build. - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: >- + fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_conda || + fromJSON(needs.changed-files.outputs.changed_file_groups).build_docs permissions: actions: read contents: read @@ -460,8 +462,7 @@ jobs: packages: read pull-requests: read uses: rapidsai/shared-workflows/.github/workflows/conda-python-tests.yaml@main - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_conda with: build_type: pull-request run_codecov: false @@ -476,8 +477,7 @@ jobs: pull-requests: read secrets: inherit # zizmor: ignore[secrets-inherit] uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@main - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).build_docs with: build_type: pull-request node_type: "gpu-l4-latest-1" @@ -593,8 +593,9 @@ jobs: pull-requests: read secrets: inherit # zizmor: ignore[secrets-inherit] uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@main - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: >- + fromJSON(needs.changed-files.outputs.changed_file_groups).test_java || + fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp with: build_type: pull-request node_type: "gpu-l4-latest-1" @@ -605,8 +606,7 @@ jobs: file_to_upload: "java/cuopt/target/" wheel-build-libcuopt: needs: [build-details, compute-matrix-filters, changed-files] - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels permissions: actions: read contents: read @@ -625,8 +625,7 @@ jobs: script: ci/build_wheel_libcuopt.sh wheel-build-cuopt: needs: [build-details, wheel-build-libcuopt, compute-matrix-filters, changed-files] - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels permissions: actions: read contents: read @@ -652,8 +651,7 @@ jobs: packages: read pull-requests: read uses: rapidsai/shared-workflows/.github/workflows/wheels-test.yaml@main - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels with: build_type: pull-request script: ci/test_wheel_cuopt.sh @@ -666,8 +664,7 @@ jobs: script-env-secret-3-value: ${{ secrets.CUOPT_AWS_SECRET_ACCESS_KEY }} wheel-build-cuopt-server: needs: [build-details, checks, compute-matrix-filters, changed-files] - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels permissions: actions: read contents: read @@ -687,8 +684,7 @@ jobs: matrix_filter: ${{ needs.compute-matrix-filters.outputs.cuopt_server_filter }} wheel-build-cuopt-sh-client: needs: [build-details, compute-matrix-filters, changed-files] - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels permissions: actions: read contents: read @@ -716,8 +712,7 @@ jobs: packages: read pull-requests: read uses: rapidsai/shared-workflows/.github/workflows/wheels-test.yaml@main - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels with: build_type: pull-request script: ci/test_wheel_cuopt_server.sh @@ -739,8 +734,7 @@ jobs: pull-requests: read secrets: inherit # zizmor: ignore[secrets-inherit] uses: ./.github/workflows/self_hosted_service_test.yaml - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels with: build_type: pull-request script: ci/test_self_hosted_service.sh diff --git a/cpp/src/utilities/logger.cpp b/cpp/src/utilities/logger.cpp index 77e065a7cc..7b2170db34 100644 --- a/cpp/src/utilities/logger.cpp +++ b/cpp/src/utilities/logger.cpp @@ -8,9 +8,6 @@ #include #include -#include -#include - namespace cuopt { struct buffered_entry { @@ -69,23 +66,11 @@ void set_console_log_callback(log_console_callback_t callback) { std::lock_guard lock(g_console_callback_mutex); g_console_callback = callback; - fprintf(stderr, - "[cuopt-logger-debug] set_console_log_callback: &g_console_callback=%p callback=%p " - "(thread=%zu)\n", - static_cast(&g_console_callback), - reinterpret_cast(callback), - std::hash{}(std::this_thread::get_id())); } static log_console_callback_t console_log_callback() { std::lock_guard lock(g_console_callback_mutex); - fprintf(stderr, - "[cuopt-logger-debug] console_log_callback(getter): &g_console_callback=%p value=%p " - "(thread=%zu)\n", - static_cast(&g_console_callback), - reinterpret_cast(g_console_callback), - std::hash{}(std::this_thread::get_id())); return g_console_callback; } @@ -183,18 +168,10 @@ static std::mutex g_guard_mutex; init_logger_t::init_logger_t(std::string log_file, bool log_to_console) { std::lock_guard lock(g_guard_mutex); - fprintf(stderr, - "[cuopt-logger-debug] init_logger_t: constructing (thread=%zu, log_to_console=%d)\n", - std::hash{}(std::this_thread::get_id()), - log_to_console); auto existing_guard = g_active_guard.lock(); if (existing_guard) { // Reuse existing configuration, just hold a reference to keep it alive - fprintf(stderr, - "[cuopt-logger-debug] init_logger_t: reusing existing guard, sinks NOT " - "reconfigured (callback registered=%d)\n", - console_log_callback() != nullptr); guard_ = existing_guard; return; } @@ -204,13 +181,9 @@ init_logger_t::init_logger_t(std::string log_file, bool log_to_console) // re-initialize sinks if (log_to_console) { if (auto callback = console_log_callback(); callback != nullptr) { - fprintf(stderr, "[cuopt-logger-debug] init_logger_t: installing callback_sink_mt\n"); cuopt::default_logger().sinks().push_back( std::make_shared(callback)); } else { - fprintf(stderr, - "[cuopt-logger-debug] init_logger_t: no callback registered yet, installing raw " - "std::cout ostream_sink_mt\n"); cuopt::default_logger().sinks().push_back( std::make_shared(std::cout)); } diff --git a/java/cuopt/src/main/native/cuopt_jni.cpp b/java/cuopt/src/main/native/cuopt_jni.cpp index 5e6eab461d..439ac63cd1 100644 --- a/java/cuopt/src/main/native/cuopt_jni.cpp +++ b/java/cuopt/src/main/native/cuopt_jni.cpp @@ -4,9 +4,7 @@ */ #include -#include #include -#include #include #include #include @@ -390,43 +388,19 @@ std::once_flag g_log_sink_once; // register_console_log_sink for why that distinction matters. void console_log_callback(int /* level */, const char* message) { - fprintf(stderr, - "[cuopt-jni-debug] console_log_callback: entered (thread=%zu, g_log_sink_class=%p, " - "g_log_sink_method=%p)\n", - std::hash{}(std::this_thread::get_id()), - static_cast(g_log_sink_class), - static_cast(g_log_sink_method)); - if (g_log_sink_class == nullptr || g_log_sink_method == nullptr) { - fprintf(stderr, - "[cuopt-jni-debug] console_log_callback: invoked but sink not registered, message=%s", - message); - return; - } + if (g_log_sink_class == nullptr || g_log_sink_method == nullptr) { return; } bool detach = false; JNIEnv* env = get_callback_env(detach); - if (env == nullptr) { - fprintf(stderr, - "[cuopt-jni-debug] console_log_callback: get_callback_env returned nullptr " - "(thread=%zu)\n", - std::hash{}(std::this_thread::get_id())); - return; - } + 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) { - fprintf(stderr, - "[cuopt-jni-debug] console_log_callback: CallStaticVoidMethod raised a pending " - "exception, clearing it\n"); - env->ExceptionClear(); - } + if (env->ExceptionCheck() == JNI_TRUE) { env->ExceptionClear(); } env->DeleteLocalRef(line); - } else { - fprintf(stderr, "[cuopt-jni-debug] console_log_callback: NewStringUTF returned null\n"); } if (detach) { g_jvm->DetachCurrentThread(); } @@ -438,19 +412,13 @@ void console_log_callback(int /* level */, const char* message) void register_console_log_sink(JNIEnv* env) { std::call_once(g_log_sink_once, [env]() { - fprintf(stderr, "[cuopt-jni-debug] register_console_log_sink: entering call_once\n"); jclass local_cls = env->FindClass("com/nvidia/cuopt/mathematicaloptimization/NativeLogSink"); if (local_cls == nullptr) { - fprintf(stderr, - "[cuopt-jni-debug] register_console_log_sink: FindClass(NativeLogSink) failed\n"); env->ExceptionClear(); return; } jmethodID method = env->GetStaticMethodID(local_cls, "onLogLine", "(Ljava/lang/String;)V"); if (method == nullptr) { - fprintf(stderr, - "[cuopt-jni-debug] register_console_log_sink: GetStaticMethodID(onLogLine) " - "failed\n"); env->ExceptionClear(); env->DeleteLocalRef(local_cls); return; @@ -459,8 +427,6 @@ void register_console_log_sink(JNIEnv* env) g_log_sink_method = method; env->DeleteLocalRef(local_cls); cuopt::set_console_log_callback(&console_log_callback); - fprintf(stderr, - "[cuopt-jni-debug] register_console_log_sink: callback registered successfully\n"); }); } From fc6443ae2d97a217b74af705bd57e912f72dbfa0 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 1 Sep 2026 07:57:32 -0500 Subject: [PATCH 21/31] Drop stale "Exploratory" framing; fix a misplaced job comment in build.yaml The java-static-* jobs are no longer exploratory, and the comments duplicated what's already explained at each script's own top-of-file #1817 reference. Kept the one bit of load-bearing rationale (why java-static-build-matrix skips conda-cpp-build) inline in pr.yaml. build.yaml also had "Combines every classifier into one Maven-repository-layout artifact... See rapidsai/build-infra#379" sitting above java-static-test, describing java-static-gather instead (25 lines further down, which had no comment of its own). Moved it to the job it actually documents. --- .github/workflows/build.yaml | 6 ++---- .github/workflows/pr.yaml | 5 ++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 684bbc71b3..d6672f0396 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -64,8 +64,6 @@ jobs: date: ${{ inputs.date }} sha: ${{ inputs.sha }} script: ci/build_cpp.sh - # Exploratory (#1817): one self-contained classifier JAR per CUDA major and architecture, - # gathered into a single Maven-repository artifact for publishing. java-static-build-matrix: permissions: contents: read @@ -100,8 +98,6 @@ jobs: artifact-name: "cuopt_java_${{ matrix.ARCH }}_cu${{ matrix.CUDA_MAJOR }}" file_to_upload: "java/cuopt/classifier-jars/" - # Combines every classifier into one Maven-repository-layout artifact, which is the form a - # publishing workflow consumes. See rapidsai/build-infra#379. # Runs the full Java suite against each classifier JAR on a GPU, with no libcuopt installed, # so a JAR that loads but computes wrong answers fails here rather than at a user. java-static-test: @@ -127,6 +123,8 @@ jobs: container_image: "rapidsai/ci-conda:26.10-latest" script: "ci/test_java_static.sh" + # Combines every classifier into one Maven-repository-layout artifact, which is the form a + # publishing workflow consumes. See rapidsai/build-infra#379. java-static-gather: needs: [java-static-build] runs-on: linux-amd64-cpu4 diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 7baf3597bc..df243e128f 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -486,9 +486,8 @@ jobs: artifact-name: "cuopt_docs" container_image: "rapidsai/ci-conda:26.10-latest" script: "ci/build_docs.sh" - # Exploratory (#1817): one self-contained classifier JAR per CUDA major and architecture, - # gathered into a single Maven-repository artifact. Compiles libcuopt from source, so it does - # not need conda-cpp-build. + # Compiles libcuopt from source (one self-contained classifier JAR per CUDA major and + # architecture), so this does not need conda-cpp-build. See #1817. java-static-build-matrix: needs: changed-files permissions: From 88b49bddc8aa56a447229116a28e9ec96ef68f08 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 1 Sep 2026 07:57:43 -0500 Subject: [PATCH 22/31] Add java/cuopt/.mvn/maven.config to reduce Maven Central rate-limiting java-static-test has repeatedly hit 429 Too Many Requests resolving plugins like maven-source-plugin from a cold repository -- the cuopt_mvn wrapper's retry loop (fixed in #1823) retries the whole mvn invocation with backoff, but that's compensating for Maven's own resolver never being tuned, and 4 attempts don't reliably outlast a sustained rate limit. cuDF and cuVS already carry this exact fix for their own Java/Maven Central builds: a project-level .mvn/maven.config (auto-applied to every mvn invocation, no wrapper needed) that caps concurrent downloads to reduce burst request rate and adds a real backoff inside Maven's own transport-layer retry handler, rather than only retrying around the outside of a failed process: -Daether.connector.basic.downstreamThreads=1 -Daether.transport.http.retryHandler.count=5 -Daether.transport.http.retryHandler.interval=10000 -Dmaven.wagon.http.retryHandler.count=5 cuopt_mvn's own -D flags target the connector-layer retry handler, which recent Maven resolver versions may no longer consult now that retry logic lives at the transport layer -- this adds the layer that actually gets read, verified via `mvn help:evaluate -Dexpression=aether.transport.http.retryHandler.interval` resolving to 10000. Verified the packaged-jar-tests suite still passes with this config present. --- java/cuopt/.mvn/maven.config | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 java/cuopt/.mvn/maven.config diff --git a/java/cuopt/.mvn/maven.config b/java/cuopt/.mvn/maven.config new file mode 100644 index 0000000000..c83cb17ff4 --- /dev/null +++ b/java/cuopt/.mvn/maven.config @@ -0,0 +1,6 @@ +-e +-B +-Daether.connector.basic.downstreamThreads=1 +-Daether.transport.http.retryHandler.count=5 +-Daether.transport.http.retryHandler.interval=10000 +-Dmaven.wagon.http.retryHandler.count=5 From 2dad31c62dc0192615fdc5af8b17a927cd111df4 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 1 Sep 2026 10:01:30 -0500 Subject: [PATCH 23/31] Move java-static-test off conda onto rapidsai/ci-wheel; temporarily trim pr.yaml to iterate Root cause investigation (see PR discussion): java-static-test's Surefire "Corrupted channel" crash was fixed by shipping cuDSS's missing threading-layer companion, but java-static-test also kept hitting Maven Central 429s that java-static-build didn't. The difference traced to test_java_static.sh doing a fresh `conda create` (openjdk+maven+cuda-version+libcublas+libcusparse) every run -- a slow solve that reliably lined up concurrent matrix jobs' cold Maven Central resolution moments apart, exactly the kind of synchronized retry collision a fixed backoff schedule can't escape. cuDF's java-tests job (same shared-workflows custom-job.yaml, same kind of packaged-jar test) doesn't hit this: it runs on rapidsai/ci-wheel, a plain CUDA-devel + dnf environment with no conda env-solve step at all. The test job only needs a JDK, Maven and the CUDA runtime (libcublas/libcusparse, already baked into that image); everything else the JAR needs is already embedded as a companion library, so there's nothing conda-specific about testing it. - test_java_static.sh: dnf-install a JDK (Rocky 8's own maven package is too old for cuOpt's plugins, so pin a modern Apache Maven tarball instead) rather than solving a conda env from scratch. - pr.yaml / build.yaml: point java-static-test's container_image at rapidsai/ci-wheel instead of rapidsai/ci-conda. java-static-build stays on conda unchanged -- unlike the test job, the build needs TBB, which has no clean dnf/pip source for both amd64 and arm64 (Rocky 8's dnf tbb-devel is the ancient 2018 API; the pip and GitHub release prebuilt binaries are x86_64-only). - Verified locally end to end against the actual rapidsai/ci-wheel image via Docker (not just -Dcuopt.native.dir): building the classifier JAR with conda unchanged, then running -Ppackaged-jar-tests inside a fresh rapidsai/ci-wheel container with zero conda. That surfaced two more build-toolchain/consumer-distro ABI gaps invisible on the current Ubuntu-based test image: libgomp, libstdc++ and libgcc_s all need newer symbol versions (OMP_5.0.1/GLIBCXX_3.4.30/GCC_14.0.0) than Rocky Linux 8's defaults ship. Fixed by shipping all three as companions, same pattern as librmm.so/libtbb.so.12/libcudss.so.0. Final local run: 38/38 tests, zero Corrupted channel, zero UnsatisfiedLinkError. pr.yaml's non-java if:false gates are back for the same reason as before: keep iterating on java-static-test alone instead of the full suite. Revert before merge. --- .github/workflows/build.yaml | 4 +- .github/workflows/pr.yaml | 56 +++++++++++-------- ci/test_java_static.sh | 43 ++++++-------- java/cuopt/ci/build_cuopt_java_jar.sh | 6 +- .../NativeLibraryLoader.java | 7 ++- 5 files changed, 64 insertions(+), 52 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d6672f0396..43dcd5a4ae 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -120,7 +120,9 @@ jobs: sha: ${{ inputs.sha }} node_type: "gpu-l4-latest-1" arch: ${{ matrix.ARCH }} - container_image: "rapidsai/ci-conda:26.10-latest" + # A plain CUDA-devel + dnf environment rather than rapidsai/ci-conda: see the matching + # comment in pr.yaml. + container_image: "rapidsai/ci-wheel:26.10-cuda${{ matrix.CUDA_VER }}-${{ matrix.LINUX_VER }}-py${{ matrix.PY_VER }}" script: "ci/test_java_static.sh" # Combines every classifier into one Maven-repository-layout artifact, which is the form a diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index df243e128f..af8e051322 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -384,11 +384,8 @@ jobs: conda-cpp-build: needs: [build-details, checks, compute-matrix-filters, changed-files] # Consumed by C++, Java, Python, and docs jobs. - if: >- - fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp || - fromJSON(needs.changed-files.outputs.changed_file_groups).test_java || - fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_conda || - fromJSON(needs.changed-files.outputs.changed_file_groups).build_docs + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false permissions: actions: read contents: read @@ -410,7 +407,8 @@ jobs: packages: read pull-requests: read uses: rapidsai/shared-workflows/.github/workflows/conda-cpp-tests.yaml@main - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false with: build_type: pull-request script: ci/test_cpp.sh @@ -430,15 +428,15 @@ jobs: packages: read pull-requests: read uses: ./.github/workflows/multi_gpu_cpp_test.yaml - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false with: build_type: pull-request conda-python-build: needs: [build-details, conda-cpp-build, changed-files] # Consumed by conda-python-tests and docs-build. - if: >- - fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_conda || - fromJSON(needs.changed-files.outputs.changed_file_groups).build_docs + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false permissions: actions: read contents: read @@ -462,7 +460,8 @@ jobs: packages: read pull-requests: read uses: rapidsai/shared-workflows/.github/workflows/conda-python-tests.yaml@main - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_conda + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false with: build_type: pull-request run_codecov: false @@ -477,7 +476,8 @@ jobs: pull-requests: read secrets: inherit # zizmor: ignore[secrets-inherit] uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@main - if: fromJSON(needs.changed-files.outputs.changed_file_groups).build_docs + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false with: build_type: pull-request node_type: "gpu-l4-latest-1" @@ -545,7 +545,11 @@ jobs: build_type: pull-request node_type: "gpu-l4-latest-1" arch: ${{ matrix.ARCH }} - container_image: "rapidsai/ci-conda:26.10-latest" + # A plain CUDA-devel + dnf environment rather than rapidsai/ci-conda: the packaged JAR + # only needs a JDK, Maven and the CUDA runtime (libcublas/libcusparse, already in this + # image) to test, and a fresh `conda create` every run was slow enough that concurrent + # matrix jobs' cold Maven Central resolution reliably lined up and triggered 429s. + container_image: "rapidsai/ci-wheel:26.10-cuda${{ matrix.CUDA_VER }}-${{ matrix.LINUX_VER }}-py${{ matrix.PY_VER }}" script: "ci/test_java_static.sh" # Surefire fork-crash diagnostics (dumpstream/hs_err files) are also printed inline by # the script on failure, but keep the raw reports downloadable too -- the JVM sometimes @@ -592,9 +596,8 @@ jobs: pull-requests: read secrets: inherit # zizmor: ignore[secrets-inherit] uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@main - if: >- - fromJSON(needs.changed-files.outputs.changed_file_groups).test_java || - fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false with: build_type: pull-request node_type: "gpu-l4-latest-1" @@ -605,7 +608,8 @@ jobs: file_to_upload: "java/cuopt/target/" wheel-build-libcuopt: needs: [build-details, compute-matrix-filters, changed-files] - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false permissions: actions: read contents: read @@ -624,7 +628,8 @@ jobs: script: ci/build_wheel_libcuopt.sh wheel-build-cuopt: needs: [build-details, wheel-build-libcuopt, compute-matrix-filters, changed-files] - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false permissions: actions: read contents: read @@ -650,7 +655,8 @@ jobs: packages: read pull-requests: read uses: rapidsai/shared-workflows/.github/workflows/wheels-test.yaml@main - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false with: build_type: pull-request script: ci/test_wheel_cuopt.sh @@ -663,7 +669,8 @@ jobs: script-env-secret-3-value: ${{ secrets.CUOPT_AWS_SECRET_ACCESS_KEY }} wheel-build-cuopt-server: needs: [build-details, checks, compute-matrix-filters, changed-files] - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false permissions: actions: read contents: read @@ -683,7 +690,8 @@ jobs: matrix_filter: ${{ needs.compute-matrix-filters.outputs.cuopt_server_filter }} wheel-build-cuopt-sh-client: needs: [build-details, compute-matrix-filters, changed-files] - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false permissions: actions: read contents: read @@ -711,7 +719,8 @@ jobs: packages: read pull-requests: read uses: rapidsai/shared-workflows/.github/workflows/wheels-test.yaml@main - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false with: build_type: pull-request script: ci/test_wheel_cuopt_server.sh @@ -733,7 +742,8 @@ jobs: pull-requests: read secrets: inherit # zizmor: ignore[secrets-inherit] uses: ./.github/workflows/self_hosted_service_test.yaml - if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels + # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. + if: false with: build_type: pull-request script: ci/test_self_hosted_service.sh diff --git a/ci/test_java_static.sh b/ci/test_java_static.sh index 1b07818ee6..1e315adaac 100755 --- a/ci/test_java_static.sh +++ b/ci/test_java_static.sh @@ -24,10 +24,6 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" . "${REPO_ROOT}/java/cuopt/scripts/maven.sh" cuopt_maven_args -if [[ -e /opt/conda/etc/profile.d/conda.sh ]]; then - . /opt/conda/etc/profile.d/conda.sh -fi - if [[ -z "${CUOPT_JAVA_JAR:-}" ]]; then case "$(arch)" in x86_64) JOB_ARCH=amd64 ;; @@ -41,34 +37,29 @@ if [[ -z "${CUOPT_JAVA_JAR:-}" ]]; then fi rapids-logger "Testing $(basename "${CUOPT_JAVA_JAR}")" -# A JDK, Maven and the CUDA runtime only. Installing libcuopt would defeat the test, since the -# JAR is supposed to carry its own copy. -rapids-logger "Creating a consumer-like environment" -ENV_YAML_DIR=$(mktemp -d) -cat > "${ENV_YAML_DIR}/env.yaml" << EOF -name: java_static_test -channels: - - conda-forge -dependencies: - - openjdk=11.* - - maven - - cuda-version=${RAPIDS_CUDA_VERSION%.*} - - libcublas - - libcusparse -EOF -rapids-mamba-retry env create --yes -f "${ENV_YAML_DIR}/env.yaml" -n java_static_test - -set +u -conda activate java_static_test -set -u +# A JDK and Maven only -- no conda, no cuOpt package. The container image (rapidsai/ci-wheel) +# already ships the CUDA runtime (libcublas/libcusparse) that the JAR dynamically links against; +# installing libcuopt itself would defeat the test, since the JAR is supposed to carry its own +# copy of everything else it needs. See #1817 and the java-static-classifiers PR discussion for +# why this moved off a fresh `conda create`: that env-solve was slow and consistently synced up +# concurrent matrix jobs' cold Maven Central resolution, which is what triggered repeated 429s. +rapids-logger "Installing a JDK (dnf's own maven package is too old; see MAVEN_VERSION below)" +MAVEN_VERSION="3.9.9" +dnf install -y java-11-openjdk-devel +export JAVA_HOME=/usr/lib/jvm/java-11-openjdk +MAVEN_HOME="$(mktemp -d)" +curl -fsSL "https://archive.apache.org/dist/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz" \ + | tar xz -C "${MAVEN_HOME}" --strip-components=1 +export PATH="${MAVEN_HOME}/bin:${JAVA_HOME}/bin:${PATH}" -if [[ -e "${CONDA_PREFIX}/lib/libcuopt.so" ]]; then +if command -v ldconfig >/dev/null 2>&1 && ldconfig -p | grep -q libcuopt.so; then echo "ERROR: libcuopt.so is present in the test environment, so passing here would not show" >&2 echo " that the JAR is self-contained." >&2 exit 1 fi -rapids-print-env +java -version +mvn -version nvidia-smi rapids-logger "Running the suite against the packaged JAR" diff --git a/java/cuopt/ci/build_cuopt_java_jar.sh b/java/cuopt/ci/build_cuopt_java_jar.sh index 061fc81ddf..862fc7037a 100755 --- a/java/cuopt/ci/build_cuopt_java_jar.sh +++ b/java/cuopt/ci/build_cuopt_java_jar.sh @@ -88,7 +88,11 @@ echo " native library -> ${RESOURCE_DIR}/libcuopt_jni.so" # libcudss_mtlayer_gomp.so.0 is cuDSS's OpenMP threading backend: cudssSetThreadingLayer # dlopen()s it at runtime. Without it that call fails and cuDSS writes the failure straight to # the process's native stdout, corrupting Maven Surefire's forked-JVM protocol. -for companion in librmm.so librapids_logger.so libtbb.so.12 libnccl.so.2 libcudss.so.0 libcudss_mtlayer_gomp.so.0; do +# libgomp.so.1, libstdc++.so.6 and libgcc_s.so.1 are the build host's GCC runtime libraries; a +# consumer's own system copies can be too old (e.g. Rocky Linux 8's defaults only go up to +# OMP_3.1, GLIBCXX_3.4.29 and GCC_7.0.0 respectively, older than what this build links against), +# so they travel alongside rather than being assumed present. +for companion in librmm.so librapids_logger.so libtbb.so.12 libnccl.so.2 libcudss.so.0 libcudss_mtlayer_gomp.so.0 libgomp.so.1 "libstdc++.so.6" libgcc_s.so.1; do companion_path="${CUOPT_PREFIX:-}/lib/${companion}" if [[ ! -f "${companion_path}" ]]; then echo "ERROR: ${companion} not found at ${companion_path}; set CUOPT_PREFIX" >&2 diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java index 31561972cb..75c837bb7f 100644 --- a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java @@ -31,8 +31,13 @@ final class NativeLibraryLoader { * writes the failure straight to the process's native stdout, corrupting Maven Surefire's * forked-JVM protocol exactly like the raw writes NativeLogSink was built to intercept -- but * from a source outside cuopt's own logger entirely, so no logging fix here can catch it. + * libgomp.so.1, libstdc++.so.6 and libgcc_s.so.1 travel too: this library is built against the + * build host's GCC runtime libraries, which can require symbol versions (e.g. OMP_5.0.1, + * GLIBCXX_3.4.30, GCC_14.0.0) newer than a consumer's own system copies ship -- observed with + * Rocky Linux 8's defaults, which only go up to OMP_3.1, GLIBCXX_3.4.29 and GCC_7.0.0 + * respectively. */ - private static final String[] COMPANION_LIBRARIES = {"librmm.so", "librapids_logger.so", "libtbb.so.12", "libnccl.so.2", "libcudss.so.0", "libcudss_mtlayer_gomp.so.0"}; + private static final String[] COMPANION_LIBRARIES = {"librmm.so", "librapids_logger.so", "libtbb.so.12", "libnccl.so.2", "libcudss.so.0", "libcudss_mtlayer_gomp.so.0", "libgomp.so.1", "libstdc++.so.6", "libgcc_s.so.1"}; private NativeLibraryLoader() {} From f373279651dfef109aca905574088e3d6b703062 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 1 Sep 2026 11:25:54 -0500 Subject: [PATCH 24/31] Only attach source/javadoc jars when actually packaging, not on every mvn test/verify Root cause of why cuDF's equivalent java-tests job never seems to hit the same Maven Central 429s ours does, even after moving to the same rapidsai/ci-wheel environment: cuDF gates maven-source-plugin and maven-javadoc-plugin behind a release-only profile, so their test path never resolves them. Ours declared both unconditionally in , so every `mvn test`/`verify` invocation -- including test_java_static.sh's, which never packages anything -- still had to resolve maven-source-plugin:3.3.1 from Maven Central on every run. That's the exact artifact that's failed on 429 in every occurrence of this issue across this investigation. Move both plugins into a new attach-source-javadoc profile, activated explicitly by ci/build_cuopt_java_jar.sh (the only place that actually needs sources/javadoc jars, for Maven Central publishing) rather than left implicit everywhere. Verified locally: `mvn test -Ppackaged-jar-tests` and `mvn compile` no longer resolve maven-source-plugin/maven-javadoc-plugin at all (checked via -X debug output); `mvn package -Pattach-source-javadoc` still produces both jars; the full packaged-jar-tests suite still passes (38/38). --- java/cuopt/ci/build_cuopt_java_jar.sh | 4 ++ java/cuopt/pom.xml | 79 ++++++++++++++++----------- 2 files changed, 51 insertions(+), 32 deletions(-) diff --git a/java/cuopt/ci/build_cuopt_java_jar.sh b/java/cuopt/ci/build_cuopt_java_jar.sh index 862fc7037a..7237105285 100755 --- a/java/cuopt/ci/build_cuopt_java_jar.sh +++ b/java/cuopt/ci/build_cuopt_java_jar.sh @@ -104,7 +104,11 @@ for companion in librmm.so librapids_logger.so libtbb.so.12 libnccl.so.2 libcuds done mkdir -p "${OUTPUT_DIR}/${CLASSIFIER}" +# -Pattach-source-javadoc: this publishes to a Maven repository, which requires sources and +# javadoc jars. Most mvn invocations (test, verify) don't activate it, since they don't +# package anything -- see the profile's own comment in pom.xml for why that distinction exists. cuopt_mvn -f "${MODULE_DIR}/pom.xml" -B \ + -Pattach-source-javadoc \ -DskipTests \ -Dcuopt.jar.classifier="${CLASSIFIER}" \ -Dcuopt.native.resources="${STAGING}" \ diff --git a/java/cuopt/pom.xml b/java/cuopt/pom.xml index 10bde6fa85..99a33a79f3 100644 --- a/java/cuopt/pom.xml +++ b/java/cuopt/pom.xml @@ -126,38 +126,6 @@ SPDX-License-Identifier: Apache-2.0 - - - org.apache.maven.plugins - maven-source-plugin - 3.3.1 - - - attach-sources - - jar-no-fork - - - - - - org.apache.maven.plugins - maven-javadoc-plugin - 3.11.2 - - - none - - - - attach-javadocs - - jar - - - - org.apache.maven.plugins maven-surefire-plugin @@ -176,6 +144,53 @@ SPDX-License-Identifier: Apache-2.0 + + + attach-source-javadoc + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.11.2 + + + none + + + + attach-javadocs + + jar + + + + + + + + + + gcs-maven-central-mirror + GCS Maven Central mirror + https://maven-central.storage-download.googleapis.com/maven2/ + + true + + + false + + + + central + Maven Central + https://repo.maven.apache.org/maven2 + + true + + + false + + + + + + + gcs-maven-central-mirror + GCS Maven Central mirror + https://maven-central.storage-download.googleapis.com/maven2/ + + true + + + false + + + + central + Maven Plugin Repository + https://repo.maven.apache.org/maven2 + + true + + + false + + + + org.junit.jupiter From 210149088c53090f4d73e340a119ec76e5123e42 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 2 Sep 2026 09:59:51 -0500 Subject: [PATCH 29/31] Clean up leftover debug/duplicate content from the merge - pr.yaml: remove the 'TEMP DEBUG ... disabled to speed up java-static-test iteration' if: false blocks left on 14 jobs from earlier fast-iteration debugging; restore each job's real changed-files condition. - logger.hpp: remove a stale inline set_console_log_callback/ console_log_callback definition left over from resolving this branch's first merge conflict with main, before #1825 landed there with its own (correct, single-instance, CUOPT_EXPORT) version further down the same file. Both defined the same symbols in the same namespace; only main's version, backed by console_log_callback.cpp, is needed. --- .github/workflows/pr.yaml | 50 ++++++++++++++++-------------------- cpp/src/utilities/logger.hpp | 28 -------------------- 2 files changed, 22 insertions(+), 56 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 9cb1837678..d3caf4919e 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -384,8 +384,11 @@ jobs: conda-cpp-build: needs: [build-details, checks, compute-matrix-filters, changed-files] # Consumed by C++, Java, Python, and docs jobs. - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: >- + fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp || + fromJSON(needs.changed-files.outputs.changed_file_groups).test_java || + fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_conda || + fromJSON(needs.changed-files.outputs.changed_file_groups).build_docs permissions: actions: read contents: read @@ -407,8 +410,7 @@ jobs: packages: read pull-requests: read uses: rapidsai/shared-workflows/.github/workflows/conda-cpp-tests.yaml@main - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp with: build_type: pull-request script: ci/test_cpp.sh @@ -428,15 +430,15 @@ jobs: packages: read pull-requests: read uses: ./.github/workflows/multi_gpu_cpp_test.yaml - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp with: build_type: pull-request conda-python-build: needs: [build-details, conda-cpp-build, changed-files] # Consumed by conda-python-tests and docs-build. - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: >- + fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_conda || + fromJSON(needs.changed-files.outputs.changed_file_groups).build_docs permissions: actions: read contents: read @@ -460,8 +462,7 @@ jobs: packages: read pull-requests: read uses: rapidsai/shared-workflows/.github/workflows/conda-python-tests.yaml@main - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_conda with: build_type: pull-request run_codecov: false @@ -476,8 +477,7 @@ jobs: pull-requests: read secrets: inherit # zizmor: ignore[secrets-inherit] uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@main - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).build_docs with: build_type: pull-request node_type: "gpu-l4-latest-1" @@ -602,8 +602,9 @@ jobs: pull-requests: read secrets: inherit # zizmor: ignore[secrets-inherit] uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@main - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: >- + fromJSON(needs.changed-files.outputs.changed_file_groups).test_java || + fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp with: build_type: pull-request node_type: "gpu-l4-latest-1" @@ -614,8 +615,7 @@ jobs: file_to_upload: "java/cuopt/target/" wheel-build-libcuopt: needs: [build-details, compute-matrix-filters, changed-files] - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels permissions: actions: read contents: read @@ -634,8 +634,7 @@ jobs: script: ci/build_wheel_libcuopt.sh wheel-build-cuopt: needs: [build-details, wheel-build-libcuopt, compute-matrix-filters, changed-files] - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels permissions: actions: read contents: read @@ -661,8 +660,7 @@ jobs: packages: read pull-requests: read uses: rapidsai/shared-workflows/.github/workflows/wheels-test.yaml@main - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels with: build_type: pull-request script: ci/test_wheel_cuopt.sh @@ -675,8 +673,7 @@ jobs: script-env-secret-3-value: ${{ secrets.CUOPT_AWS_SECRET_ACCESS_KEY }} wheel-build-cuopt-server: needs: [build-details, checks, compute-matrix-filters, changed-files] - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels permissions: actions: read contents: read @@ -696,8 +693,7 @@ jobs: matrix_filter: ${{ needs.compute-matrix-filters.outputs.cuopt_server_filter }} wheel-build-cuopt-sh-client: needs: [build-details, compute-matrix-filters, changed-files] - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels permissions: actions: read contents: read @@ -725,8 +721,7 @@ jobs: packages: read pull-requests: read uses: rapidsai/shared-workflows/.github/workflows/wheels-test.yaml@main - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels with: build_type: pull-request script: ci/test_wheel_cuopt_server.sh @@ -748,8 +743,7 @@ jobs: pull-requests: read secrets: inherit # zizmor: ignore[secrets-inherit] uses: ./.github/workflows/self_hosted_service_test.yaml - # TEMP DEBUG (java-static-classifiers): disabled to speed up java-static-test iteration. Revert before merge. - if: false + if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels with: build_type: pull-request script: ci/test_self_hosted_service.sh diff --git a/cpp/src/utilities/logger.hpp b/cpp/src/utilities/logger.hpp index d2b3bc596d..a21fbc04f4 100644 --- a/cpp/src/utilities/logger.hpp +++ b/cpp/src/utilities/logger.hpp @@ -45,34 +45,6 @@ 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: From 651e791f0f126716de3056e5e8193595f510a8d6 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 2 Sep 2026 10:29:49 -0500 Subject: [PATCH 30/31] Address CodeRabbit review findings - ci/test_java_static.sh: verify the downloaded Maven tarball against Apache's published SHA-512 before extracting it. - console_log_callback.cpp: move the callback mutex to function-local static storage instead of a non-trivially-destructible namespace-scope global, per repo coding guidelines. - verify_jar_dependencies.sh: fail loudly if readelf is missing or yields no DT_NEEDED entries at all, instead of silently reporting a vacuous self-contained pass. - assemble_maven_repo.sh: exclude sources/javadoc JARs from the version-probe lookup, matching the classifier lookup below it. - NativeLibraryLoader.java: harden the shared extraction directory (reject symlinks, require same-owner reuse, restrict permissions on creation) and verify cached native libraries by SHA-256 digest rather than size alone before reusing them. Not addressed: pinning the three rapidsai/shared-workflows@main references in build.yaml to commit SHAs. Every other reference to those reusable workflows in this file (17 of them) already uses @main, matching the convention every other RAPIDS repo's CI uses for the same workflows; pinning only the 3 lines this PR touches would be inconsistent without actually improving security, since the other 17 remain floating. --- ci/test_java_static.sh | 10 +- .../console_log_callback.cpp | 15 ++- java/cuopt/ci/assemble_maven_repo.sh | 3 +- java/cuopt/ci/verify_jar_dependencies.sh | 15 +++ .../NativeLibraryLoader.java | 96 ++++++++++++++++--- 5 files changed, 122 insertions(+), 17 deletions(-) diff --git a/ci/test_java_static.sh b/ci/test_java_static.sh index cfcf4b797e..501639aa8a 100755 --- a/ci/test_java_static.sh +++ b/ci/test_java_static.sh @@ -48,8 +48,14 @@ MAVEN_VERSION="3.9.9" dnf install -y java-11-openjdk-devel export JAVA_HOME=/usr/lib/jvm/java-11-openjdk MAVEN_HOME="$(mktemp -d)" -curl -fsSL "https://archive.apache.org/dist/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz" \ - | tar xz -C "${MAVEN_HOME}" --strip-components=1 +MAVEN_TARBALL="$(mktemp)" +MAVEN_TARBALL_URL="https://archive.apache.org/dist/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz" +curl -fsSL "${MAVEN_TARBALL_URL}" -o "${MAVEN_TARBALL}" +# archive.apache.org is plain HTTPS-authenticated hosting, not a signed package index, so verify +# the download against Apache's published SHA-512 rather than trusting transport security alone. +echo "$(curl -fsSL "${MAVEN_TARBALL_URL}.sha512") ${MAVEN_TARBALL}" | sha512sum --check --status +tar xz -C "${MAVEN_HOME}" --strip-components=1 -f "${MAVEN_TARBALL}" +rm -f "${MAVEN_TARBALL}" export PATH="${MAVEN_HOME}/bin:${JAVA_HOME}/bin:${PATH}" if command -v ldconfig >/dev/null 2>&1 && ldconfig -p | grep -q libcuopt.so; then diff --git a/cpp/src/math_optimization/console_log_callback.cpp b/cpp/src/math_optimization/console_log_callback.cpp index 1b9f738fc6..1d73239895 100644 --- a/cpp/src/math_optimization/console_log_callback.cpp +++ b/cpp/src/math_optimization/console_log_callback.cpp @@ -12,19 +12,28 @@ namespace cuopt { namespace { -std::mutex g_console_callback_mutex; + +// A function-local static, not a namespace-scope global: std::mutex is not trivially +// destructible, and a namespace-scope instance would be torn down in an unspecified order +// relative to other static destructors at exit. +std::mutex& console_callback_mutex() +{ + static std::mutex mutex; + return mutex; +} + log_console_callback_t g_console_callback = nullptr; } // namespace void set_console_log_callback(log_console_callback_t callback) { - std::lock_guard lock(g_console_callback_mutex); + std::lock_guard lock(console_callback_mutex()); g_console_callback = callback; } log_console_callback_t console_log_callback() { - std::lock_guard lock(g_console_callback_mutex); + std::lock_guard lock(console_callback_mutex()); return g_console_callback; } diff --git a/java/cuopt/ci/assemble_maven_repo.sh b/java/cuopt/ci/assemble_maven_repo.sh index 292a213807..4230f36666 100755 --- a/java/cuopt/ci/assemble_maven_repo.sh +++ b/java/cuopt/ci/assemble_maven_repo.sh @@ -66,7 +66,8 @@ fi # The version is read from a JAR name rather than the POM, so the layout can only ever describe # artifacts that are actually present. -first_jar="$(find "${JARS_DIR}" -name "${ARTIFACT_ID}-*.jar" -print -quit)" +first_jar="$(find "${JARS_DIR}" -name "${ARTIFACT_ID}-*-*.jar" \ + ! -name '*-sources.jar' ! -name '*-javadoc.jar' -print -quit)" if [[ -z "${first_jar}" ]]; then echo "no ${ARTIFACT_ID}-*.jar found under ${JARS_DIR}" >&2 exit 1 diff --git a/java/cuopt/ci/verify_jar_dependencies.sh b/java/cuopt/ci/verify_jar_dependencies.sh index ebffb1dda9..d428bccf08 100755 --- a/java/cuopt/ci/verify_jar_dependencies.sh +++ b/java/cuopt/ci/verify_jar_dependencies.sh @@ -83,11 +83,18 @@ soname_stem() { sed -E 's/\.so\.[0-9.]+$/.so/' <<< "$1"; } allowed_external=("${ALLOWED_CUDA_LIBRARIES[@]}" "${ALLOWED_SYSTEM_LIBRARIES[@]}") unsatisfied=() +if ! command -v readelf >/dev/null 2>&1; then + echo "ERROR: readelf not found; cannot verify native dependencies" >&2 + exit 1 +fi + echo echo "Checking DT_NEEDED of every packaged library" +needed_seen=0 for lib in "${NATIVE_DIR}"/*.so*; do while read -r needed; do [[ -z "${needed}" ]] && continue + needed_seen=$((needed_seen + 1)) # Packaged beside it, so the $ORIGIN RPATH resolves it. if [[ -e "${NATIVE_DIR}/${needed}" ]]; then continue @@ -106,6 +113,14 @@ for lib in "${NATIVE_DIR}"/*.so*; do done < <(readelf -d "${lib}" 2>/dev/null | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p') done +# readelf failing silently (missing tool, corrupt ELF, empty NATIVE_DIR) would otherwise leave +# unsatisfied empty and this script would report a false "self-contained" pass -- exactly the +# kind of gap this script exists to catch, so treat it as a hard failure instead. +if [[ "${needed_seen}" -eq 0 ]]; then + echo "ERROR: no DT_NEEDED entries were read from any packaged library" >&2 + exit 1 +fi + if [[ ${#unsatisfied[@]} -gt 0 ]]; then echo >&2 echo "ERROR: the JAR is not self-contained. Unsatisfied dependencies:" >&2 diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java index 75c837bb7f..1dece49662 100644 --- a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java @@ -9,8 +9,13 @@ import java.io.UncheckedIOException; import java.net.URL; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; /** * Locates and loads {@code libcuopt_jni}, in three steps. @@ -101,11 +106,7 @@ private static Path extractEmbeddedLibraries() { } try { - Path directory = - Path.of( - System.getProperty("java.io.tmpdir"), - "cuopt-native-" + System.getProperty("user.name", "shared")); - Files.createDirectories(directory); + Path directory = privateExtractionDirectory(); for (String companion : COMPANION_LIBRARIES) { extractResource(resourcePath(osArch, companion), directory, companion); @@ -120,10 +121,12 @@ private static Path extractEmbeddedLibraries() { * Copies one packaged file into {@code directory} and returns it, or null when the JAR does not * contain it. * - *

A file already there with the expected size is reused rather than rewritten, because the JNI - * library is hundreds of megabytes and re-extracting it on every JVM start would dominate - * startup. It is written to a sibling and moved into place, so an interrupted run cannot leave a - * truncated library behind for the next one to load. + *

A file already there whose digest matches the packaged resource is reused rather than + * rewritten, because the JNI library is hundreds of megabytes and re-extracting it on every JVM + * start would dominate startup. Comparing digests rather than just size means a file another + * process happened to leave at the same size cannot be mistaken for the real library. It is + * written to a sibling and moved into place, so an interrupted run cannot leave a truncated + * library behind for the next one to load. */ private static Path extractResource(String resource, Path directory, String fileName) throws IOException { @@ -132,8 +135,11 @@ private static Path extractResource(String resource, Path directory, String file return null; } Path target = directory.resolve(fileName); - long expectedSize = url.openConnection().getContentLengthLong(); - if (expectedSize >= 0 && Files.isRegularFile(target) && Files.size(target) == expectedSize) { + byte[] expectedDigest; + try (InputStream in = url.openStream()) { + expectedDigest = digest(in); + } + if (Files.isRegularFile(target) && Arrays.equals(expectedDigest, digest(target))) { return target; } Path staging = Files.createTempFile(directory, fileName + ".", ".part"); @@ -145,4 +151,72 @@ private static Path extractResource(String resource, Path directory, String file } return target; } + + private static byte[] digest(Path path) throws IOException { + try (InputStream in = Files.newInputStream(path)) { + return digest(in); + } + } + + private static byte[] digest(InputStream in) throws IOException { + MessageDigest sha256; + try { + sha256 = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + // Mandatory per the Java platform spec; every conforming JVM provides it. + throw new IllegalStateException("SHA-256 unavailable", e); + } + byte[] buffer = new byte[1 << 16]; + int n; + while ((n = in.read(buffer)) != -1) { + sha256.update(buffer, 0, n); + } + return sha256.digest(); + } + + /** + * A directory private to the current OS user, reused across JVM runs so the (potentially + * hundreds-of-megabytes) native libraries are extracted once rather than on every start. + * + *

{@code java.io.tmpdir} is typically world-writable, so a fixed, predictable path under it + * is only safe to reuse if it is verified private on every use: otherwise another local user + * could pre-create it -- as a symlink elsewhere, or simply owned by them -- ahead of this + * process and have {@link #extractResource} write into a location of their choosing before this + * process ever runs, or read files this process wrote expecting them to be private. Refuse to + * proceed rather than silently extracting into an untrusted directory. + */ + private static Path privateExtractionDirectory() throws IOException { + Path directory = + Path.of( + System.getProperty("java.io.tmpdir"), + "cuopt-native-" + System.getProperty("user.name", "shared")); + + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectories(directory); + try { + Files.setPosixFilePermissions(directory, PosixFilePermissions.fromString("rwx------")); + } catch (UnsupportedOperationException e) { + // Non-POSIX filesystem (e.g. Windows), which has no equivalent world-writable-tmpdir + // risk to guard against here. + } + return directory; + } + + if (Files.isSymbolicLink(directory)) { + throw new IOException(directory + " is a symlink; refusing to extract native libraries " + + "through it"); + } + try { + String owner = Files.getOwner(directory).getName(); + String currentUser = System.getProperty("user.name"); + if (currentUser != null && !currentUser.equals(owner)) { + throw new IOException( + directory + " is owned by '" + owner + "', not the current user; refusing to " + + "extract native libraries into it"); + } + } catch (UnsupportedOperationException e) { + // Non-POSIX filesystem; ownership isn't a meaningful concept to check here. + } + return directory; + } } From b7497f1a746fce2ea00d9d72498b20a868748771 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 2 Sep 2026 11:39:21 -0500 Subject: [PATCH 31/31] Simplify NativeLibraryLoader's cache-reuse check back to a size comparison The digest-based comparison added a full SHA-256 read of both the packaged resource and any existing cached copy on every JVM startup, defeating much of the point of caching the (hundreds-of-megabytes) extracted library in the first place. privateExtractionDirectory() already closes the actual attack this was guarding against -- a same-size file planted by another local user -- by refusing to reuse the directory unless it's private to the current OS user. With that in place, a plain size check is enough. --- .../NativeLibraryLoader.java | 46 ++++--------------- 1 file changed, 10 insertions(+), 36 deletions(-) diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java index 1dece49662..ace7a61776 100644 --- a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicaloptimization/NativeLibraryLoader.java @@ -13,9 +13,6 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.PosixFilePermissions; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Arrays; /** * Locates and loads {@code libcuopt_jni}, in three steps. @@ -121,12 +118,14 @@ private static Path extractEmbeddedLibraries() { * Copies one packaged file into {@code directory} and returns it, or null when the JAR does not * contain it. * - *

A file already there whose digest matches the packaged resource is reused rather than - * rewritten, because the JNI library is hundreds of megabytes and re-extracting it on every JVM - * start would dominate startup. Comparing digests rather than just size means a file another - * process happened to leave at the same size cannot be mistaken for the real library. It is - * written to a sibling and moved into place, so an interrupted run cannot leave a truncated - * library behind for the next one to load. + *

A file already there with the expected size is reused rather than rewritten, because the + * JNI library is hundreds of megabytes and re-extracting it on every JVM start would dominate + * startup. This is only safe because {@link #privateExtractionDirectory} guarantees {@code + * directory} is private to the current OS user: relying on size alone in a directory anyone + * could write to would let another user's same-size file pass as the real library. + * + *

It is written to a sibling and moved into place, so an interrupted run cannot leave a + * truncated library behind for the next one to load. */ private static Path extractResource(String resource, Path directory, String fileName) throws IOException { @@ -135,11 +134,8 @@ private static Path extractResource(String resource, Path directory, String file return null; } Path target = directory.resolve(fileName); - byte[] expectedDigest; - try (InputStream in = url.openStream()) { - expectedDigest = digest(in); - } - if (Files.isRegularFile(target) && Arrays.equals(expectedDigest, digest(target))) { + long expectedSize = url.openConnection().getContentLengthLong(); + if (expectedSize >= 0 && Files.isRegularFile(target) && Files.size(target) == expectedSize) { return target; } Path staging = Files.createTempFile(directory, fileName + ".", ".part"); @@ -152,28 +148,6 @@ private static Path extractResource(String resource, Path directory, String file return target; } - private static byte[] digest(Path path) throws IOException { - try (InputStream in = Files.newInputStream(path)) { - return digest(in); - } - } - - private static byte[] digest(InputStream in) throws IOException { - MessageDigest sha256; - try { - sha256 = MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - // Mandatory per the Java platform spec; every conforming JVM provides it. - throw new IllegalStateException("SHA-256 unavailable", e); - } - byte[] buffer = new byte[1 << 16]; - int n; - while ((n = in.read(buffer)) != -1) { - sha256.update(buffer, 0, n); - } - return sha256.digest(); - } - /** * A directory private to the current OS user, reused across JVM runs so the (potentially * hundreds-of-megabytes) native libraries are extracted once rather than on every start.