Ship a JVM-consumable native shared library#233
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adds a Bazel-built, JVM-consumable native DDS shared library (libdds.so / libdds.dylib / dds.dll) that exports only the stable C ABI, plus a Java Foreign Function & Memory (FFM / Panama) binding and smoke test to exercise the shim end-to-end.
Changes:
- Introduces a pure-C ABI shim (
dds_c_*) over the modernSolverContextAPI and builds it into a singlecc_shared_libraryartifact. - Adds deterministic export control (Linux version script + macOS exported-symbols list) and tests to ensure the shared library exports only the intended public C symbols.
- Adds hand-written Java FFM bindings (
org.dds.ffm) and a non-JUnit smoke test that loads the shared library and solves a known deal.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| MODULE.bazel.lock | Updates bzlmod lock metadata to reflect new module inputs. |
| MODULE.bazel | Adds rules_java dependency to support hermetic JDK toolchain usage. |
| .bazelrc | Pins Bazel Java language/runtime/toolchain versions to remote JDK 25 for stable java.lang.foreign. |
| library/src/api/BUILD.bazel | Exposes new shim header and adds a dds_c_api C++ target for the shim implementation. |
| library/src/api/dds_c_api.h | Declares the pure-C, pointer/POD-only shim API surface (dds_c_*). |
| library/src/api/dds_c_api.cpp | Implements shim functions by forwarding to dds_* context API. |
| jni/BUILD.bazel | Adds dds_shared shared library target plus dds_ffm Java bindings + smoke test wiring. |
| jni/version_script.lds | Linux linker version script enumerating the exported symbol allowlist. |
| jni/exported_symbols.lds | macOS exported-symbols list (underscore-prefixed) enumerating the allowlist. |
| jni/gen_export_lists.py | Tool to parse public headers and generate Linux/macOS export lists deterministically. |
| jni/tests/BUILD.bazel | Adds Python tests for export-set enforcement and export-list parsing. |
| jni/tests/gen_export_lists_test.py | Unit-tests the export parser against header snippets. |
| jni/tests/export_set_test.py | Validates the built shared library exports exactly the allowlisted public symbols. |
| jni/java/org/dds/ffm/Dds.java | Hand-written FFM layouts and downcall handles for dds_c_* + GetDDSInfo. |
| jni/java/org/dds/ffm/DdsSmokeTest.java | End-to-end smoke test that loads the library and calls the shim via FFM. |
| docs/jni_interface.md | Documents how to build/use the shared library + FFM bindings under Bazel. |
Addresses review findings in the packaging layer:
- Arch mismatch (finding 1): the native staging genrules hardcoded the arch
token (macos-aarch64, linux-x86_64) while Dds.loadEmbedded() resolves arch
from os.arch at runtime — so on an x86_64 Mac or aarch64 Linux the embedded
path never matched, and a cross-arch jar could load a wrong-arch binary. The
genrules are now keyed on os+cpu (five triplets) via target_compatible_with,
so the embedded path's arch always matches the build, and a differing runtime
arch fails cleanly (resource not found) instead of loading the wrong binary.
- Duplicate compilation (finding 3): java_export now exports :dds_ffm instead
of re-listing srcs, so Dds.java is compiled once and there is a single source
of truth (verified the jar still bundles Dds.class + the native lib).
- Windows copy (finding 4): the Windows genrule gains cmd_bat ("copy /Y") so
the dist jar builds on a Windows host without msys/coreutils cp.
- Version divergence (finding 5): the Maven version is a single DDS_FFM_VERSION
constant, and the embedded smoke test now cross-checks the coordinate's
major.minor against the library's GetDDSInfo (patch may differ: package vs
C-API versioning).
Co-Authored-By: Claude <noreply@anthropic.com>
Dds.load() allocated a shared Arena before SymbolLookup.libraryLookup and the downcall binding; if either threw (wrong-arch/missing library, missing symbol) the shared arena — which is not auto-managed — leaked its native memory session for the JVM lifetime, accumulating across repeated failed loads. Now the arena is closed on failure before rethrowing (finding 6). Co-Authored-By: Claude <noreply@anthropic.com>
export_set_test previously took its expected symbol set from the same version_script.lds that drives what the linker exports, so it could not detect a public symbol added to a header but never regenerated into the .lds (both sides would lack it and the test passed green). It now parses the public headers (dll.h, dds_c_api.h) via gen_export_lists.parse_symbols and diffs the built library against that, so a stale .lds surfaces as a missing export. Exposes the two headers via exports_files for the test to read (finding 2). Co-Authored-By: Claude <noreply@anthropic.com>
- Windows copy (residual finding): replace the staging genrules with bazel_skylib copy_file, which copies without a shell, removing the cmd.exe `copy` forward-slash misparsing risk (and the cp/coreutils assumption on any host). Adds bazel_skylib 1.8.1. - Select with no default (residual finding): dds_ffm_dist now carries target_compatible_with that marks it incompatible on any os/cpu outside the five supported triplets, so `bazel build //...` skips it there instead of aborting with "no matching conditions" and poisoning unrelated builds. The resources select also gains a //conditions:default for analysis safety. Co-Authored-By: Claude <noreply@anthropic.com>
The embedded smoke test loads via the Bazel runtime classpath, where dds_ffm classes are present through java_export's `exports` edge regardless of whether they were bundled into the Maven artifact. jar_self_contained_test inspects the built jar file directly and asserts it contains both org/dds/ffm/Dds.class and a native/<triplet>/ library, so a regression where the artifact ships without the classes (which a coordinate-only Maven consumer would hit as NoClassDefFoundError) is caught (residual finding). Co-Authored-By: Claude <noreply@anthropic.com>
Drop the `-U` flag, whose meaning differs between BSD nm and llvm/GNU nm, and decide defined-vs-undefined from the symbol-type column instead (uppercase `T` = external defined text). `-g` still restricts to external symbols. This avoids a wrong exported set when a BSD-style nm is on PATH (residual finding). Co-Authored-By: Claude <noreply@anthropic.com>
rules_jvm_external pulls bazel_skylib 1.9.0, so pinning the direct dependency to 1.8.1 printed a version-mismatch warning on every bazel invocation. Align the pin with the resolved version to silence it. Co-Authored-By: Claude <noreply@anthropic.com>
- dds_c_api.cpp: guard all shim entry points against NULL handles/args (return RETURN_UNKNOWN_FAULT) and wrap bodies in catch-all handlers so no C++ exception can unwind across the pure-C ABI boundary - dds_c_api.h / Dds.java: fix grammar "an RETURN_*" -> "a RETURN_*" - DdsSmokeTest.readCString: bound the NUL scan to the segment size and fail with a clear error on a missing terminator - DdsSmokeTest.locateLibrary: cap the fallback Files.walk depth so a missing dds.library.path cannot trigger an unbounded tree traversal Co-Authored-By: Claude <noreply@anthropic.com>
- dds_c_api.cpp: give dds_c_destroy_solvercontext a NULL guard and a catch-all so it follows the shim's own no-exceptions-across-the-C-ABI rule (consistent with the other entry points) - DdsSmokeTest / DdsEmbeddedSmokeTest: explicitly zero-fill the Deal and DdTableDeal input structs after arena.allocate(), which the FFM spec does not guarantee to be zeroed, so currentTrick*/unset cards[] are deterministic - docs/jni_interface.md: drop the incorrect "zero-initialised" claim and show an explicit deal.fill((byte) 0) in the example Co-Authored-By: Claude <noreply@anthropic.com>
- DdsSmokeTest / DdsEmbeddedSmokeTest: correct the fixture comment; North wins by leading a trump (spade) every trick, not by ruffing (no hand is ever void-and-trumping here) Note: the Windows export-list comment on jni/BUILD.bazel was intentionally left as-is per maintainer decision (Windows export behavior unchanged). Co-Authored-By: Claude <noreply@anthropic.com>
- Dds.osToken: detect "linux" explicitly and throw UnsupportedOperationException for any other OS instead of silently falling back to a linux resource path - Dds.archToken: throw for unknown architectures instead of returning the raw os.arch, which would build a resource path the jar never embeds - DdsSmokeTest.checkSolveRejectsInvalidDeal: use the full valid 52-card fixture and change only the trump so the rejection is attributable to trump-range validation, not an incomplete deal Co-Authored-By: Claude <noreply@anthropic.com>
- Dds.rethrow: propagate Error (OutOfMemoryError, LinkageError, ...) unchanged instead of wrapping it in RuntimeException, so JVM-fatal conditions are not hidden behind callers' RuntimeException handling - DdsSmokeTest.locateLibrary: treat a non-blank dds.library.path as authoritative -- resolve it to a regular file or fail fast, never silently fall back to scanning the tree (which could load a different libdds.*); a blank/unset property still falls back Co-Authored-By: Claude <noreply@anthropic.com>
- dds_c_api.h: correct the misleading "Pure-C ABI" header wording. The exported symbols are a pure C ABI, but the header itself is not C-compilable because it includes dll.h, whose flat API uses C++ trailing-return syntax. Document how to consume the ABI (bind to the compiled symbols, or parse headers in C++ mode via jextract) and that it must not be #included from a C translation unit. Co-Authored-By: Claude <noreply@anthropic.com>
- DdsSmokeTest.readCString: bound the NUL scan to the specific char[] field length (derived from the layout: 1024 for DDSInfo.systemString, 16 for the NS entry of ParResults.parScore[2][16]) instead of the whole struct, so a missing terminator cannot run into an adjacent field and return a bogus concatenated string. Length is clamped to the segment to keep the out-of-bounds guard. Co-Authored-By: Claude <noreply@anthropic.com>
- dds_c_api.cpp: convert hard-tab indentation to 4 spaces per the repo C++ style guide (.github/instructions/cpp.instructions.md: "4 spaces ... No hard tabs"); whitespace-only, object code unchanged - library/src/api/BUILD.bazel: replace the stray hard tab on the dds_api.hpp header line with spaces to match the rest of the list - .bazelrc: reword the Java-toolchain comment; the flags are unconditional build options (apply to every build) but only Java actions consult the toolchain, so clarify they have no effect on C++/WASM/Python rather than implying they are scoped to //jni Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Expose the DDS RETURN_* status codes to Java consumers (tzimnoch): the
dll.h constants were not visible from the FFM bindings, so callers only
had bare ints to compare against.
- add DdsStatus: public int constants mirroring dll.h RETURN_* one-for-one
(name + value), each documented with its meaning, plus name(int) for
symbolic diagnostics
- wire DdsStatus.java into the //jni:dds_ffm library
- reference {@link DdsStatus} from the Dds solveBoard/calcDdTable/calcPar
javadocs
- replace the duplicated local RETURN_NO_FAULT in both smoke tests with a
static import of DdsStatus.RETURN_NO_FAULT; use DdsStatus.name(rc) in the
invalid-deal diagnostics
- document the codes in jni_interface.md
Chose int constants over an enum: the ABI returns a raw int, so
`rc == DdsStatus.RETURN_NO_FAULT` stays idiomatic and matches the
jextract-style bindings; name(int) covers the readable-diagnostics need.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
- Dds.java: clarify that the MemoryLayouts match dll.h in size/offset/padding but use camelCase field names, listing the names that differ from the C identifiers so the layouts can be cross-checked against the header. - DdsSmokeTest.locateLibrary(): collect and sort all matching libdds.* rather than Files.walk(...).findFirst(), which is filesystem-order dependent; fail fast with a clear message on 0 or >1 candidates. - jni/BUILD.bazel + docs/jni_interface.md: document that the Windows DLL has no export-list branch and therefore exports a superset of the Linux/macOS set, and what closing that gap requires (.def file + dumpbin arm of export_set_test). Co-Authored-By: Claude <noreply@anthropic.com>
zzcgumn
force-pushed
the
feat/shared_lib_and_jni
branch
from
July 18, 2026 14:08
85a37a9 to
31d4351
Compare
rules_jvm_external (added for the //jni Maven jar) depends on rules_android, whose android_sdk_repository extension probes $ANDROID_HOME during analysis of any target. GitHub's ubuntu runners export ANDROID_HOME but the WASM workflow's free-disk-space step deletes the SDK, so the extension found a path with zero platform APIs and hard-failed — taking down //web and //wasm, which have nothing to do with Java or Android. Pin ANDROID_HOME to empty via --repo_env so the extension emits its empty stub repo, which is the path non-Android builds are meant to take. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds build support that produces a single native shared library exporting the stable DDS C ABI, plus a Java Foreign Function & Memory (FFM / Project Panama) binding that calls it — making the solver consumable from the JVM the same way it already is from Python, .NET, and the browser.
What's in here
Layer 1 — the shared library (independently useful; also unblocks .NET/ctypes/JNA)
library/src/api/dds_c_api.h/.cpp) wrapping the modernSolverContextAPI with a C-valid, pointer-only, POD-only surface over an opaquevoid*handle — so non-C++ consumers never seeSolverConfig/TTKind/C++ references.//jni:dds_shared cc_shared_libraryproducing one self-containedlibdds.so/libdds.dylib/dds.dllwith the whole solver linked in statically.jni/gen_export_lists.pygenerates a Linux version-script and a macOS exported-symbols list from the headers;//jni/tests:export_set_testasserts the library exports exactly the 44 public C symbols (39 flatdll.h+ 5dds_c_*) with zero C++-mangled leakage.Layer 2 — JVM consumption
rules_javaand a hermetic JDK toolchain (remotejdk_25, pinned via.bazelrc); JDK ≥ 22 meansjava.lang.foreignis stable, no--enable-preview.//jni:dds_ffm— hand-writtenorg.dds.ffmFFM bindings:struct MemoryLayoutsandLinkerdowncall handles for the shim plusGetDDSInfo.//jni:dds_ffm_smoke_test— loads the built library and solves a known deal through the shim, assertingcards=1, score[0]=13(cross-checked against the Python binding).docs/jni_interface.md— usage guide mirroring the existing per-binding docs.### Notable design decisions
plain java.lang.foreigncode, the bindings are hand-written instead — the build stays fully hermetic.rules_java9.6.1 registers onlyremotejdk_21/_25; 25 satisfies the ≥22 FFM-stable requirement.DDS_C_SOLVER_CTX(notDDS_SOLVER_CTX) to avoid a conflicting typedef withdds_api.hpp.-fvisibility=hidden(exports are constrained at link time instead).Testing
bazel build //jni:dds_sharedbazel test //jni/... # export_set_test + dds_ffm_smoke_testbazel test //python/... //web/... # existing bindings unaffectedFull //... buildand the existing test suites pass. The new tests are scoped off Windows (different export/dumper model and library path).🤖 Generated with Claude Code
Closes #231