Skip to content

Ship a JVM-consumable native shared library#233

Merged
zzcgumn merged 43 commits into
developfrom
feat/shared_lib_and_jni
Jul 18, 2026
Merged

Ship a JVM-consumable native shared library#233
zzcgumn merged 43 commits into
developfrom
feat/shared_lib_and_jni

Conversation

@zzcgumn

@zzcgumn zzcgumn commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

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)

  • Pure-C ABI shim (library/src/api/dds_c_api.h/.cpp) wrapping the modern SolverContext API with a C-valid, pointer-only, POD-only surface over an opaque void* handle — so non-C++ consumers never see SolverConfig/TTKind/C++ references.
  • //jni:dds_shared cc_shared_library producing one self-contained libdds.so / libdds.dylib / dds.dll with the whole solver linked in statically.
  • Link-time export control: jni/gen_export_lists.py generates a Linux version-script and a macOS exported-symbols list from the headers; //jni/tests:export_set_test asserts the library exports exactly the 44 public C symbols (39 flat dll.h + 5 dds_c_*) with zero C++-mangled leakage.

Layer 2 — JVM consumption

  • Adds rules_java and a hermetic JDK toolchain (remotejdk_25, pinned via .bazelrc); JDK ≥ 22 means java.lang.foreign is stable, no --enable-preview.
  • //jni:dds_ffm — hand-written org.dds.ffm FFM bindings: struct MemoryLayouts and Linker downcall handles for the shim plus GetDDSInfo.
  • //jni:dds_ffm_smoke_test — loads the built library and solves a known deal through the shim, asserting cards=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

  • No jextract. It ships only as rot-prone early-access binaries (not on BCR, no stable URL/hash), which would make the build non-hermetic. Since it only generates plain java.lang.foreigncode, the bindings are hand-written instead — the build stays fully hermetic.
  • JDK 25, not exactly 22. rules_java 9.6.1 registers only remotejdk_21/_25; 25 satisfies the ≥22 FFM-stable requirement.
  • Handle named DDS_C_SOLVER_CTX (not DDS_SOLVER_CTX) to avoid a conflicting typedef with dds_api.hpp.
  • Deferred (non-goals): hand-written JNI convenience API, native-in-JAR/Maven packaging, and switching the whole build to -fvisibility=hidden (exports are constrained at link time instead).

Testing

  • bazel build //jni:dds_shared
  • bazel test //jni/... # export_set_test + dds_ffm_smoke_test
  • bazel test //python/... //web/... # existing bindings unaffected
  • Full //... build and 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 modern SolverContext API and builds it into a single cc_shared_library artifact.
  • 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.

Comment thread library/src/api/dds_c_api.cpp
Comment thread library/src/api/dds_c_api.cpp
Comment thread library/src/api/dds_c_api.cpp
Comment thread library/src/api/dds_c_api.h Outdated
Comment thread jni/java/org/dds/ffm/Dds.java Outdated
Comment thread jni/java/org/dds/ffm/Dds.java Outdated
Comment thread jni/java/org/dds/ffm/Dds.java Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 7 comments.

Comment thread library/src/api/dds_c_api.cpp
Comment thread library/src/api/dds_c_api.cpp
Comment thread library/src/api/dds_c_api.cpp
Comment thread library/src/api/dds_c_api.cpp
Comment thread jni/java/org/dds/ffm/DdsSmokeTest.java Outdated
Comment thread jni/java/org/dds/ffm/DdsSmokeTest.java Outdated
Comment thread library/src/api/dds_c_api.h Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 7 comments.

Comment thread library/src/api/dds_c_api.cpp
Comment thread jni/java/org/dds/ffm/DdsSmokeTest.java
Comment thread jni/java/org/dds/ffm/DdsSmokeTest.java Outdated
Comment thread jni/java/org/dds/ffm/DdsSmokeTest.java
Comment thread jni/java/org/dds/ffm/DdsSmokeTest.java
Comment thread jni/java/org/dds/ffm/DdsEmbeddedSmokeTest.java
Comment thread docs/jni_interface.md Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 3 comments.

Comment thread jni/BUILD.bazel
Comment thread jni/java/org/dds/ffm/DdsSmokeTest.java Outdated
Comment thread jni/java/org/dds/ffm/DdsEmbeddedSmokeTest.java Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 3 comments.

Comment thread jni/java/org/dds/ffm/Dds.java
Comment thread jni/java/org/dds/ffm/Dds.java
Comment thread jni/java/org/dds/ffm/DdsSmokeTest.java

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 2 comments.

Comment thread jni/java/org/dds/ffm/Dds.java
Comment thread jni/java/org/dds/ffm/DdsSmokeTest.java

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 1 comment.

Comment thread library/src/api/dds_c_api.h Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 1 comment.

Comment thread jni/java/org/dds/ffm/DdsSmokeTest.java Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 3 comments.

Comment thread library/src/api/dds_c_api.cpp
Comment thread library/src/api/BUILD.bazel
Comment thread .bazelrc
zzcgumn and others added 27 commits July 18, 2026 15:08
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
zzcgumn force-pushed the feat/shared_lib_and_jni branch from 85a37a9 to 31d4351 Compare July 18, 2026 14:08
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>
@zzcgumn
zzcgumn merged commit 706c742 into develop Jul 18, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create a Shared Library Target Suitable for Java Native Interface.

4 participants