Ship a JVM-consumable native shared library#233
Open
zzcgumn wants to merge 26 commits into
Open
Conversation
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>
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>
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. |
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>
- 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>
Comment on lines
+273
to
+278
| private static RuntimeException rethrow(Throwable t) { | ||
| if (t instanceof RuntimeException re) { | ||
| return re; | ||
| } | ||
| return new RuntimeException(t); | ||
| } |
Comment on lines
+223
to
+229
| String prop = System.getProperty("dds.library.path"); | ||
| if (prop != null) { | ||
| Path p = Path.of(prop); | ||
| if (Files.exists(p)) { | ||
| return p; | ||
| } | ||
| } |
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