Skip to content

Ship a JVM-consumable native shared library#233

Open
zzcgumn wants to merge 24 commits into
developfrom
feat/shared_lib_and_jni
Open

Ship a JVM-consumable native shared library#233
zzcgumn wants to merge 24 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

zzcgumn and others added 8 commits July 14, 2026 10:18
New dds_c_api.h/.cpp expose pointer-only, POD-only entry points
(dds_c_solve_board, dds_c_calc_dd_table, dds_c_calc_par, create/destroy)
over an opaque handle, forwarding to the reference-taking dds_* functions.

Deviation from task 01: the handle is named DDS_C_SOLVER_CTX (void*) rather
than DDS_SOLVER_CTX, because dds_api.hpp already typedefs DDS_SOLVER_CTX to
SolverContext* and the shim .cpp includes both headers — reusing the name
would be a conflicting typedef. The shim delegates to the existing dds_*
wrappers rather than reimplementing SolverContext lifetime.

Co-Authored-By: Claude <noreply@anthropic.com>
jni/gen_export_lists.py parses dll.h and dds_c_api.h for their DLLEXPORT
function symbols and emits a Linux version script and a macOS
exported-symbols list from the same set so they cannot drift. The generated
jni/version_script.lds and jni/exported_symbols.lds cover the 39 flat dll.h
symbols plus the 5 dds_c_* shim symbols. Output is deterministic (sorted).

Co-Authored-By: Claude <noreply@anthropic.com>
//jni:dds_shared rolls the static //library/src:dds closure plus the
dds_c_api shim into one self-contained libdds.{so,dylib}/dds.dll, with
per-OS naming and link-time export control via the generated .lds files
(version-script on Linux, exported_symbols_list on macOS, __declspec on
Windows).

//jni/tests:export_set_test dumps the built library's dynamic symbols and
asserts the exported set equals exactly the 44 public C symbols with no
C++-mangled leakage. Scoped off Windows (different export/dumper model).

Co-Authored-By: Claude <noreply@anthropic.com>
Adds rules_java 9.6.1 and pins the Java toolchain to its hermetic
remotejdk_25 via .bazelrc (--java_language_version/--java_runtime_version
and the tool_* equivalents). JDK 25 is >= 22, so java.lang.foreign is a
stable API and needs no --enable-preview. Verified in-repo that
Linker.nativeLinker() resolves; full //... build and existing tests
(python smoke, export_set_test) still pass.

Deviations from task 04: rules_java 9.6.1 registers only remotejdk_21 and
remotejdk_25, so JDK 25 is used rather than exactly 22. jextract is NOT
wired: it ships only as rot-prone early-access binaries (not on BCR, no
stable URL/hash), which would make the build non-hermetic. Since jextract
only generates plain java.lang.foreign code, the Task 05 bindings will be
hand-written FFM instead, keeping the build fully hermetic.

Co-Authored-By: Claude <noreply@anthropic.com>
//jni:dds_ffm provides java.lang.foreign struct MemoryLayouts (Deal,
FutureTricks, DdTableDeal, DdTableResults, DDSInfo) and Linker downcall
handles for the dds_c_* shim plus GetDDSInfo. Loading uses
SymbolLookup.libraryLookup over an owned Arena; the class is AutoCloseable.

Deviation from task 05: bindings are hand-written rather than
jextract-generated (jextract is non-hermetic; see task 04 commit). The
layouts mirror library/src/api/dll.h exactly, including the 2-byte padding
after DDSInfo.version_string[10].

Co-Authored-By: Claude <noreply@anthropic.com>
//jni:dds_ffm_smoke_test loads libdds.dylib/.so via SymbolLookup, queries
GetDDSInfo, then solves a known deal through the dds_c_* shim and asserts the
double dummy result (cards=1, score[0]=13 tricks) — the value cross-checked
against the Python binding. Runs as a plain main (no JUnit dep); the shared
lib is passed via -Ddds.library.path=$(rootpath ...) with native access
enabled. Field offsets are derived from the public MemoryLayouts. Scoped off
Windows.

Co-Authored-By: Claude <noreply@anthropic.com>
Documents building //jni:dds_shared, the pure-C dds_c_* shim contract, the
hand-written org.dds.ffm FFM bindings, a worked solve_board example, library
loading via SymbolLookup with --enable-native-access, threading/lifecycle,
and the deferred JNI/JAR-packaging follow-ups. Mirrors the existing per-binding
docs (python/dotnet/wasm).

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@zzcgumn zzcgumn self-assigned this Jul 14, 2026
zzcgumn and others added 2 commits July 14, 2026 11:30
Addresses review gaps in the new binding surface:

- Extend DdsSmokeTest to exercise the previously untested shim paths:
  dds_c_calc_dd_table (asserts the full 5x4 DD table against values
  cross-checked with the Python binding) and dds_c_calc_par (asserts a
  non-empty NS par score). This also covers the DD_TABLE_DEAL /
  DD_TABLE_RESULTS layouts, which nothing allocated before.
- Add a PAR_RESULTS MemoryLayout to Dds so calc_par is bindable/testable.
- Assert an error-code path for solve_board (invalid trump -> rc != success).
- Add gen_export_lists_test: unit-tests parse_symbols directly on header
  snippets (flat trailing-return form, shim pointer form, #define/comment
  exclusion, dedup+sort), independent of the linked library.

Co-Authored-By: Claude <noreply@anthropic.com>

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
zzcgumn and others added 12 commits July 14, 2026 16:08
Adds rules_jvm_external 7.0, which provides java_export (a Maven-coordinate
jar plus a generated .publish target supporting file:// local repos). Needed
to package the FFM bindings as a native-embedding jar installable into ~/.m2.
Full //... build unaffected.

Co-Authored-By: Claude <noreply@anthropic.com>
loadEmbedded() locates the bundled native library on the classpath at
/native/<os>-<arch>/<lib>, extracts it to a temp file (deleteOnExit), and
delegates to load(Path) — removing the need for callers to locate and pass a
filesystem path. Adds osToken/archToken/libFileName normalisation matching the
native/<triplet>/ layout the packaging genrule stages. load(Path) and the
existing smoke test are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Stages the host //jni:dds_shared under native/<os>-<arch>/ (one genrule per OS,
gated by target_compatible_with since genrule outs are not configurable) and
adds a java_export producing the publishable jar org.dds:dds-ffm:3.0.1 with the
native library embedded (resource_strip_prefix yields native/<triplet>/<lib> at
the jar root) plus the generated //jni:dds_ffm_dist.publish target.

Note: java_export emits the artifact as dds_ffm_dist-project.jar. Verified it
contains org/dds/ffm/Dds.class and native/macos-aarch64/libdds.dylib.

Co-Authored-By: Claude <noreply@anthropic.com>
//jni:dds_ffm_embedded_smoke_test loads the native library via
Dds.loadEmbedded() from the packaged jar's classpath resource (depends on
:dds_ffm_dist) and solves the known deal, asserting cards=1, score[0]=13 —
proving the embed -> extract -> load chain end to end. Off Windows.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds a "Packaging & local install" section to docs/jni_interface.md covering
the org.dds:dds-ffm:3.0.1 coordinate, the bazel run //jni:dds_ffm_dist.publish
command for local ~/.m2, and Gradle/Maven consumer snippets. Presents
Dds.loadEmbedded() as the default loader alongside Dds.load(Path), and trims
"Not yet supported" to remote publishing + multi-arch fat jar (native-in-jar
packaging is now done for the host platform). Local publish verified end to end
against a throwaway file:// repo.

Co-Authored-By: Claude <noreply@anthropic.com>
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>

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
Comment thread jni/java/org/dds/ffm/DdsSmokeTest.java Outdated
Comment thread library/src/api/dds_c_api.h Outdated
- 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>

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
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
- 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>

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 on lines +66 to +69
user_link_flags = select({
"//:build_macos": ["-Wl,-exported_symbols_list,$(location :exported_symbols.lds)"],
"//:build_windows": [],
"//conditions:default": ["-Wl,--version-script=$(location :version_script.lds)"],
Comment on lines +86 to +89
// North holds all spades, East all hearts, South all diamonds, West all
// clubs; spades are trump and North leads. North ruffs every trick, so
// the double dummy result is 13 tricks (score[0]) taking the ace of
// spades (verified against the Python binding).
Comment on lines +39 to +40
// North all spades (trump), East hearts, South diamonds, West clubs;
// North ruffs every trick -> 13 tricks. Cross-checked in DdsSmokeTest.
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.

2 participants