From c44f8f394203792da886e351727c8a1cf97c0943 Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Sun, 19 Jul 2026 16:28:17 +0100 Subject: [PATCH 01/21] Extend the pure-C shim with the nine missing entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .NET binding calls fourteen modern context functions, but the shim covered only five, so the other nine had no C-ABI equivalent and the Unix shared library could not satisfy them. Adds dds_c_create_solvercontext, dds_c_calc_dd_table_pbn, the TT configure/resize/clear trio, both resets, and the two logging passthroughs. Each follows the file's existing pattern: null guards before use and a catch-all so no C++ exception unwinds through the C ABI boundary. SolverConfig is decomposed into three int parameters rather than mirrored as a C struct — passing a struct by value is the ABI question this shim exists to avoid, and a mirror type would be a second definition to keep in sync. TTKind crosses as an int; the C++ enum class and the C# enum already agree on 0 = Small, 1 = Large. Co-Authored-By: Claude Opus 4.8 --- library/src/api/dds_c_api.cpp | 115 ++++++++++++++++++++++++++++++++++ library/src/api/dds_c_api.h | 27 ++++++++ 2 files changed, 142 insertions(+) diff --git a/library/src/api/dds_c_api.cpp b/library/src/api/dds_c_api.cpp index a66499e6..ff9892e6 100644 --- a/library/src/api/dds_c_api.cpp +++ b/library/src/api/dds_c_api.cpp @@ -92,4 +92,119 @@ DLLEXPORT int dds_c_calc_par(DDS_C_SOLVER_CTX ctx, } } +DLLEXPORT DDS_C_SOLVER_CTX dds_c_create_solvercontext(int tt_kind, + int def_mb, int max_mb) +{ + try { + SolverConfig cfg; + cfg.tt_kind_ = static_cast(tt_kind); + cfg.tt_mem_default_mb_ = def_mb; + cfg.tt_mem_maximum_mb_ = max_mb; + return static_cast(dds_create_solvercontext(cfg)); + } catch (...) { + return nullptr; + } +} + +DLLEXPORT int dds_c_calc_dd_table_pbn(DDS_C_SOLVER_CTX ctx, + const struct DdTableDealPBN* deal, + struct DdTableResults* results) +{ + if (ctx == nullptr || deal == nullptr || results == nullptr) + return RETURN_UNKNOWN_FAULT; + + try { + return dds_calc_dd_table_pbn(static_cast(ctx), + *deal, results); + } catch (...) { + return RETURN_UNKNOWN_FAULT; + } +} + +DLLEXPORT void dds_c_configure_tt(DDS_C_SOLVER_CTX ctx, int tt_kind, + int def_mb, int max_mb) +{ + if (ctx == nullptr) + return; + + try { + dds_configure_tt(static_cast(ctx), + static_cast(tt_kind), def_mb, max_mb); + } catch (...) { + // Must not unwind through the C ABI boundary. + } +} + +DLLEXPORT void dds_c_resize_tt(DDS_C_SOLVER_CTX ctx, int def_mb, int max_mb) +{ + if (ctx == nullptr) + return; + + try { + dds_resize_tt(static_cast(ctx), def_mb, max_mb); + } catch (...) { + // Must not unwind through the C ABI boundary. + } +} + +DLLEXPORT void dds_c_clear_tt(DDS_C_SOLVER_CTX ctx) +{ + if (ctx == nullptr) + return; + + try { + dds_clear_tt(static_cast(ctx)); + } catch (...) { + // Must not unwind through the C ABI boundary. + } +} + +DLLEXPORT void dds_c_reset_for_solve(DDS_C_SOLVER_CTX ctx) +{ + if (ctx == nullptr) + return; + + try { + dds_reset_for_solve(static_cast(ctx)); + } catch (...) { + // Must not unwind through the C ABI boundary. + } +} + +DLLEXPORT void dds_c_reset_best_moves_lite(DDS_C_SOLVER_CTX ctx) +{ + if (ctx == nullptr) + return; + + try { + dds_reset_best_moves_lite(static_cast(ctx)); + } catch (...) { + // Must not unwind through the C ABI boundary. + } +} + +DLLEXPORT void dds_c_log_append(DDS_C_SOLVER_CTX ctx, const char* msg) +{ + if (ctx == nullptr || msg == nullptr) + return; + + try { + dds_log_append(static_cast(ctx), msg); + } catch (...) { + // Must not unwind through the C ABI boundary. + } +} + +DLLEXPORT void dds_c_log_clear(DDS_C_SOLVER_CTX ctx) +{ + if (ctx == nullptr) + return; + + try { + dds_log_clear(static_cast(ctx)); + } catch (...) { + // Must not unwind through the C ABI boundary. + } +} + } // extern "C" diff --git a/library/src/api/dds_c_api.h b/library/src/api/dds_c_api.h index 975b1024..1e11053c 100644 --- a/library/src/api/dds_c_api.h +++ b/library/src/api/dds_c_api.h @@ -54,6 +54,33 @@ DLLEXPORT int dds_c_calc_par(DDS_C_SOLVER_CTX ctx, struct DdTableResults* results, struct ParResults* par); +/* Creation with explicit transposition-table configuration. The C++ SolverConfig + is decomposed into scalars rather than mirrored as a struct: passing a struct + by value is exactly the ABI question this shim exists to avoid, and a mirror + type would be a second definition to keep in sync. tt_kind: 0 = Small, + 1 = Large (matching enum class TTKind). Returns NULL on failure. */ +DLLEXPORT DDS_C_SOLVER_CTX dds_c_create_solvercontext(int tt_kind, + int def_mb, int max_mb); + +/* Compute the double dummy table from a PBN-format deal. */ +DLLEXPORT int dds_c_calc_dd_table_pbn(DDS_C_SOLVER_CTX ctx, + const struct DdTableDealPBN* deal, + struct DdTableResults* results); + +/* Transposition-table configuration. */ +DLLEXPORT void dds_c_configure_tt(DDS_C_SOLVER_CTX ctx, int tt_kind, + int def_mb, int max_mb); +DLLEXPORT void dds_c_resize_tt(DDS_C_SOLVER_CTX ctx, int def_mb, int max_mb); +DLLEXPORT void dds_c_clear_tt(DDS_C_SOLVER_CTX ctx); + +/* Per-solve state resets. */ +DLLEXPORT void dds_c_reset_for_solve(DDS_C_SOLVER_CTX ctx); +DLLEXPORT void dds_c_reset_best_moves_lite(DDS_C_SOLVER_CTX ctx); + +/* Logging passthrough. msg is a NUL-terminated UTF-8 string. */ +DLLEXPORT void dds_c_log_append(DDS_C_SOLVER_CTX ctx, const char* msg); +DLLEXPORT void dds_c_log_clear(DDS_C_SOLVER_CTX ctx); + #ifdef __cplusplus } #endif From 1bdd384b0f983ca1df6b12df34904d08a64a1624 Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Sun, 19 Jul 2026 17:01:10 +0100 Subject: [PATCH 02/21] Regenerate export lists for the widened C shim Publishes the nine shim symbols added in the previous commit through the Unix export lists, so libdds.{so,dylib} exports all fourteen dds_c_* entry points instead of five. Both .lds files are generated artifacts, regenerated with gen_export_lists.py rather than hand-edited. export_set_test derives its expectations from the headers, so it validated the result with no wiring change; the FFM smoke tests confirm the JVM binding is unaffected. Co-Authored-By: Claude Opus 4.8 --- jni/exported_symbols.lds | 9 +++++++++ jni/version_script.lds | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/jni/exported_symbols.lds b/jni/exported_symbols.lds index 1e4e0980..da713285 100644 --- a/jni/exported_symbols.lds +++ b/jni/exported_symbols.lds @@ -38,7 +38,16 @@ _SolveAllChunksPBN _SolveBoard _SolveBoardPBN _dds_c_calc_dd_table +_dds_c_calc_dd_table_pbn _dds_c_calc_par +_dds_c_clear_tt +_dds_c_configure_tt +_dds_c_create_solvercontext _dds_c_create_solvercontext_default _dds_c_destroy_solvercontext +_dds_c_log_append +_dds_c_log_clear +_dds_c_reset_best_moves_lite +_dds_c_reset_for_solve +_dds_c_resize_tt _dds_c_solve_board diff --git a/jni/version_script.lds b/jni/version_script.lds index d02f1be6..c35bd936 100644 --- a/jni/version_script.lds +++ b/jni/version_script.lds @@ -40,9 +40,18 @@ SolveBoard; SolveBoardPBN; dds_c_calc_dd_table; + dds_c_calc_dd_table_pbn; dds_c_calc_par; + dds_c_clear_tt; + dds_c_configure_tt; + dds_c_create_solvercontext; dds_c_create_solvercontext_default; dds_c_destroy_solvercontext; + dds_c_log_append; + dds_c_log_clear; + dds_c_reset_best_moves_lite; + dds_c_reset_for_solve; + dds_c_resize_tt; dds_c_solve_board; local: *; From 2bc191ec0811680dcff4ca1da32b42ed9c3bd068 Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Sun, 19 Jul 2026 17:47:30 +0100 Subject: [PATCH 03/21] Add C++ coverage for the pure-C shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shim had no dedicated C++ test — its five original functions were covered only indirectly via the JVM smoke tests. That was tolerable for five thin forwarders, less so for fourteen, and the new group includes the shim's first void-returning and first string-taking entry points whose null guards nothing exercised. Covers null-handle and null-argument rejection, both TT kinds, TT reconfiguration, the resets, logging, and PBN/binary DD-table agreement. The reference board matches DdsSmokeTest.java so the JVM, .NET, and C++ bindings share one fixture. Depends on //library/src:dds explicitly rather than only transitively through dds_c_api: dds.cpp carries the constructor that initializes static solver memory, and without a direct edge the linker drops it. Includes one DISABLED_ test recording a pre-existing library bug found while writing this: on a Small TT, clear_tt() releases the pools and a following reset_for_solve() re-inits over them, dereferencing null in TransTableS::init_tt(). Verified reachable through the C++ API with no dds_c_* call involved, so it predates this shim and affects the existing Windows .NET path. Disabled rather than deleted so the fault stays recorded without breaking the build. Co-Authored-By: Claude Opus 4.8 --- library/tests/BUILD.bazel | 24 +++ library/tests/dds_c_api_test.cpp | 287 +++++++++++++++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 library/tests/dds_c_api_test.cpp diff --git a/library/tests/BUILD.bazel b/library/tests/BUILD.bazel index 2509ff72..28277318 100644 --- a/library/tests/BUILD.bazel +++ b/library/tests/BUILD.bazel @@ -11,6 +11,7 @@ filegroup( "calc_par_test.cpp", # Uses GoogleTest, compiled separately "test_timer_test.cpp", # Uses GoogleTest, compiled separately "args_test.cpp", # Uses GoogleTest, compiled separately + "dds_c_api_test.cpp", # Uses GoogleTest, compiled separately ], ), ) @@ -58,6 +59,29 @@ cc_test( ], ) +# Exercises the pure-C shim through its own ABI, including the null guards and +# catch-all wrappers that exist only at that boundary and would be bypassed by +# calling the reference-taking dds_* functions directly. +# +# Depends on //library/src:dds explicitly, not just transitively through +# dds_c_api: dds.cpp carries the __attribute__((constructor)) that initializes +# static solver memory, and without a direct edge the linker drops that object +# file — leaving TransTableS::init_tt() to dereference null on the first solve +# after a TT-kind switch. The shipped //jni:dds_shared already depends on both. +cc_test( + name = "dds_c_api_test", + srcs = ["dds_c_api_test.cpp"], + size = "small", + copts = DDS_CPPOPTS, + linkopts = DDS_LINKOPTS, + local_defines = DDS_LOCAL_DEFINES, + deps = [ + "//library/src:dds", + "//library/src/api:dds_c_api", + "@googletest//:gtest_main", + ], +) + cc_test( name = "test_timer_test", srcs = [ diff --git a/library/tests/dds_c_api_test.cpp b/library/tests/dds_c_api_test.cpp new file mode 100644 index 00000000..4f23f927 --- /dev/null +++ b/library/tests/dds_c_api_test.cpp @@ -0,0 +1,287 @@ +/* + DDS, a bridge double dummy solver. + + Tests the pure-C ABI shim through its own boundary. + + These call dds_c_* rather than the reference-taking dds_* functions on + purpose: the null guards and the catch-all wrappers exist only in the shim, + so exercising the C++ API directly would bypass exactly the code under test. + + The reference board matches jni/java/org/dds/ffm/DdsSmokeTest.java so the + JVM, .NET, and C++ bindings all agree on one fixture. + + See LICENSE and README. +*/ + +#include + +#include + +#include + +namespace { + +// Full 13-card holding bitmask (ranks 2..A), matching the Java/Python fixtures. +constexpr unsigned int kFullSuit = 0x7FFCU; + +// The reference board: North holds all spades, East all hearts, South all +// diamonds, West all clubs. With spades trump and North to lead, North/South +// take all 13 tricks. +constexpr int kExpectedTricks = 13; + +// res_table[strain][hand], flattened 5 strains x 4 hands. Cross-checked against +// the JVM binding's EXPECTED_DD_TABLE. +constexpr int kExpectedDdTable[DDS_STRAINS][DDS_HANDS] = { + {13, 0, 13, 0}, // spades + {0, 13, 0, 13}, // hearts + {13, 0, 13, 0}, // diamonds + {0, 13, 0, 13}, // clubs + {0, 0, 0, 0}, // no-trump +}; + +// The same board in PBN: ... per hand. +constexpr const char* kReferencePbn = + "N:AKQJT98765432... .AKQJT98765432.. ..AKQJT98765432. ...AKQJT98765432"; + +struct Deal MakeReferenceDeal() +{ + struct Deal dl = {}; // value-initialize; the shim does not zero for us + dl.trump = 0; // spades + dl.first = 0; // North leads + dl.remainCards[0][0] = kFullSuit; // North spades + dl.remainCards[1][1] = kFullSuit; // East hearts + dl.remainCards[2][2] = kFullSuit; // South diamonds + dl.remainCards[3][3] = kFullSuit; // West clubs + return dl; +} + +struct DdTableDeal MakeReferenceTableDeal() +{ + struct DdTableDeal deal = {}; + deal.cards[0][0] = kFullSuit; + deal.cards[1][1] = kFullSuit; + deal.cards[2][2] = kFullSuit; + deal.cards[3][3] = kFullSuit; + return deal; +} + +// Solve the reference board on ctx and return the trick count. +int SolveReference(DDS_C_SOLVER_CTX ctx) +{ + const struct Deal dl = MakeReferenceDeal(); + struct FutureTricks fut = {}; + const int rc = dds_c_solve_board(ctx, &dl, -1, 1, 1, &fut); + EXPECT_EQ(rc, RETURN_NO_FAULT); + return fut.score[0]; +} + +// --------------------------------------------------------------------------- +// Null-handle safety. Every entry point must reject a null handle rather than +// dereferencing it: the int-returning ones with RETURN_UNKNOWN_FAULT, the +// void-returning ones by returning quietly. +// --------------------------------------------------------------------------- + +TEST(DdsCApiNullHandle, IntReturningEntryPointsFailFast) +{ + const struct Deal dl = MakeReferenceDeal(); + const struct DdTableDeal table_deal = MakeReferenceTableDeal(); + struct DdTableDealPBN pbn_deal = {}; + struct FutureTricks fut = {}; + struct DdTableResults results = {}; + struct ParResults par = {}; + + EXPECT_EQ(dds_c_solve_board(nullptr, &dl, -1, 1, 1, &fut), RETURN_UNKNOWN_FAULT); + EXPECT_EQ(dds_c_calc_dd_table(nullptr, &table_deal, &results), RETURN_UNKNOWN_FAULT); + EXPECT_EQ(dds_c_calc_dd_table_pbn(nullptr, &pbn_deal, &results), RETURN_UNKNOWN_FAULT); + EXPECT_EQ(dds_c_calc_par(nullptr, &table_deal, 0, &results, &par), RETURN_UNKNOWN_FAULT); +} + +TEST(DdsCApiNullHandle, VoidReturningEntryPointsAreNoOps) +{ + // Each must return without dereferencing; the test passing is the assertion. + dds_c_destroy_solvercontext(nullptr); + dds_c_configure_tt(nullptr, 1, 0, 0); + dds_c_resize_tt(nullptr, 0, 0); + dds_c_clear_tt(nullptr); + dds_c_reset_for_solve(nullptr); + dds_c_reset_best_moves_lite(nullptr); + dds_c_log_append(nullptr, "ignored"); + dds_c_log_clear(nullptr); + SUCCEED(); +} + +TEST(DdsCApiNullArgument, PointerArgumentsAreValidated) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + struct DdTableResults results = {}; + struct DdTableDealPBN pbn_deal = {}; + const struct DdTableDeal table_deal = MakeReferenceTableDeal(); + struct FutureTricks fut = {}; + + EXPECT_EQ(dds_c_solve_board(ctx, nullptr, -1, 1, 1, &fut), RETURN_UNKNOWN_FAULT); + EXPECT_EQ(dds_c_calc_dd_table_pbn(ctx, nullptr, &results), RETURN_UNKNOWN_FAULT); + EXPECT_EQ(dds_c_calc_dd_table_pbn(ctx, &pbn_deal, nullptr), RETURN_UNKNOWN_FAULT); + EXPECT_EQ(dds_c_calc_par(ctx, &table_deal, 0, &results, nullptr), RETURN_UNKNOWN_FAULT); + + // A null message must be ignored rather than passed through to strlen. + dds_c_log_append(ctx, nullptr); + + dds_c_destroy_solvercontext(ctx); +} + +// --------------------------------------------------------------------------- +// Functional paths for the newly added entry points. +// --------------------------------------------------------------------------- + +class DdsCApiConfiguredContext : public testing::TestWithParam {}; + +TEST_P(DdsCApiConfiguredContext, SolvesReferenceBoard) +{ + // tt_kind 0 = Small, 1 = Large; both must produce a usable context. + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext(GetParam(), 0, 0); + ASSERT_NE(ctx, nullptr); + + EXPECT_EQ(SolveReference(ctx), kExpectedTricks); + + dds_c_destroy_solvercontext(ctx); +} + +INSTANTIATE_TEST_SUITE_P(BothTtKinds, DdsCApiConfiguredContext, + testing::Values(0, 1)); + +TEST(DdsCApiTtConfiguration, ContextRemainsUsableAfterReconfiguration) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + ASSERT_EQ(SolveReference(ctx), kExpectedTricks); + + // Reconfigure, resize, and clear the TT, then confirm the context still + // solves correctly — the point is that these calls do not corrupt state. + dds_c_configure_tt(ctx, 0, 1, 2); + dds_c_resize_tt(ctx, 1, 2); + dds_c_clear_tt(ctx); + EXPECT_EQ(SolveReference(ctx), kExpectedTricks); + + dds_c_destroy_solvercontext(ctx); +} + +TEST(DdsCApiResets, ResetsLeaveContextUsable) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + ASSERT_EQ(SolveReference(ctx), kExpectedTricks); + + dds_c_reset_for_solve(ctx); + dds_c_reset_best_moves_lite(ctx); + EXPECT_EQ(SolveReference(ctx), kExpectedTricks); + + dds_c_destroy_solvercontext(ctx); +} + +// Pre-existing library bug, NOT a shim defect — disabled so it documents the +// fault without breaking the build. +// +// Sequence: solve, switch the TT kind to Small, clear_tt(), then +// reset_for_solve(). clear_tt() routes to TransTableS::return_all_memory(), +// which releases the pools; reset_for_solve() then calls +// reset_memory(ResetReason::FreeMemory) -> init_tt(), which dereferences the +// now-null pw_/pl_ pools and segfaults. +// +// Verified reachable through the reference-taking C++ API +// (dds_configure_tt / dds_clear_tt / dds_reset_for_solve) with no dds_c_* +// call involved, so it predates this shim and affects the existing Windows +// .NET path too. The Large TT (the default) is unaffected. +// +// Re-enable once TransTableS reallocates its pools on reset. +TEST(DdsCApiTtConfiguration, DISABLED_SmallTtClearThenResetForSolve) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + ASSERT_EQ(SolveReference(ctx), kExpectedTricks); + dds_c_configure_tt(ctx, 0 /* Small */, 1, 2); + dds_c_clear_tt(ctx); + dds_c_reset_for_solve(ctx); // <-- segfaults today + EXPECT_EQ(SolveReference(ctx), kExpectedTricks); + + dds_c_destroy_solvercontext(ctx); +} + +TEST(DdsCApiLogging, AppendAndClearAreCallable) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + dds_c_log_append(ctx, "dds_c_api_test"); + dds_c_log_append(ctx, ""); + dds_c_log_clear(ctx); + + // Logging must not disturb solving. + EXPECT_EQ(SolveReference(ctx), kExpectedTricks); + + dds_c_destroy_solvercontext(ctx); +} + +TEST(DdsCApiDdTable, BinaryTableMatchesExpected) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + const struct DdTableDeal deal = MakeReferenceTableDeal(); + struct DdTableResults results = {}; + ASSERT_EQ(dds_c_calc_dd_table(ctx, &deal, &results), RETURN_NO_FAULT); + + for (int strain = 0; strain < DDS_STRAINS; ++strain) + for (int hand = 0; hand < DDS_HANDS; ++hand) + EXPECT_EQ(results.res_table[strain][hand], kExpectedDdTable[strain][hand]) + << "res_table[" << strain << "][" << hand << "]"; + + dds_c_destroy_solvercontext(ctx); +} + +TEST(DdsCApiDdTable, PbnTableMatchesBinaryTable) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + const struct DdTableDeal binary_deal = MakeReferenceTableDeal(); + struct DdTableResults binary_results = {}; + ASSERT_EQ(dds_c_calc_dd_table(ctx, &binary_deal, &binary_results), + RETURN_NO_FAULT); + + struct DdTableDealPBN pbn_deal = {}; + std::snprintf(pbn_deal.cards, sizeof pbn_deal.cards, "%s", kReferencePbn); + struct DdTableResults pbn_results = {}; + ASSERT_EQ(dds_c_calc_dd_table_pbn(ctx, &pbn_deal, &pbn_results), + RETURN_NO_FAULT); + + for (int strain = 0; strain < DDS_STRAINS; ++strain) + for (int hand = 0; hand < DDS_HANDS; ++hand) + EXPECT_EQ(pbn_results.res_table[strain][hand], + binary_results.res_table[strain][hand]) + << "res_table[" << strain << "][" << hand << "]"; + + dds_c_destroy_solvercontext(ctx); +} + +TEST(DdsCApiPar, ProducesNonEmptyScore) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + const struct DdTableDeal deal = MakeReferenceTableDeal(); + struct DdTableResults results = {}; + struct ParResults par = {}; + ASSERT_EQ(dds_c_calc_par(ctx, &deal, 0 /* vulnerable: none */, &results, &par), + RETURN_NO_FAULT); + + EXPECT_GT(std::strlen(par.par_score[0]), 0U); + + dds_c_destroy_solvercontext(ctx); +} + +} // namespace From 922f8233fde926acdeddb3bf1f95809ba58be578 Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Sun, 19 Jul 2026 18:01:32 +0100 Subject: [PATCH 04/21] Fix null deref in TransTableS::reset_memory after memory release return_all_memory() frees pw_/pn_/pl_ and clears tt_in_use_, leaving make_tt() to reallocate lazily before the next lookup. reset_memory() did not honour that flag: it called init_tt() unconditionally, which reads pw_[0] and segfaults on the freed pools. Reachable from the public API as configure_tt(Small) -> clear_tt() -> reset_for_solve() with no shim involved, so it also affects the existing C++ and Windows .NET paths. The Large TT was never affected because TransTableL::reset_memory() already guards the equivalent case with `pool_ == nullptr`; this adds the direct analogue for the Small TT. Found while adding shim coverage; the previously DISABLED_ regression test in dds_c_api_test is now enabled and passing. Co-Authored-By: Claude Opus 4.8 --- library/src/trans_table/trans_table_s.cpp | 9 +++++++++ library/tests/dds_c_api_test.cpp | 24 +++++++---------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/library/src/trans_table/trans_table_s.cpp b/library/src/trans_table/trans_table_s.cpp index 5cacd1e8..eaa57240 100644 --- a/library/src/trans_table/trans_table_s.cpp +++ b/library/src/trans_table/trans_table_s.cpp @@ -343,6 +343,15 @@ auto TransTableS::init_tt() -> void auto TransTableS::reset_memory( [[maybe_unused]] const ResetReason reason) -> void { + // Nothing to reset when the pools have been returned: return_all_memory() + // frees pw_/pn_/pl_ and clears tt_in_use_, and make_tt() reallocates lazily + // before the next lookup. Without this guard init_tt() below dereferences + // the freed pools (pw_[0]) and segfaults — reachable from the public API as + // configure_tt(Small) -> clear_tt() -> reset_for_solve(). TransTableL's + // reset_memory() already guards the equivalent case with `pool_ == nullptr`. + if (!tt_in_use_) + return; + wipe(); init_tt(); diff --git a/library/tests/dds_c_api_test.cpp b/library/tests/dds_c_api_test.cpp index 4f23f927..dc51322f 100644 --- a/library/tests/dds_c_api_test.cpp +++ b/library/tests/dds_c_api_test.cpp @@ -182,22 +182,12 @@ TEST(DdsCApiResets, ResetsLeaveContextUsable) dds_c_destroy_solvercontext(ctx); } -// Pre-existing library bug, NOT a shim defect — disabled so it documents the -// fault without breaking the build. -// -// Sequence: solve, switch the TT kind to Small, clear_tt(), then -// reset_for_solve(). clear_tt() routes to TransTableS::return_all_memory(), -// which releases the pools; reset_for_solve() then calls -// reset_memory(ResetReason::FreeMemory) -> init_tt(), which dereferences the -// now-null pw_/pl_ pools and segfaults. -// -// Verified reachable through the reference-taking C++ API -// (dds_configure_tt / dds_clear_tt / dds_reset_for_solve) with no dds_c_* -// call involved, so it predates this shim and affects the existing Windows -// .NET path too. The Large TT (the default) is unaffected. -// -// Re-enable once TransTableS reallocates its pools on reset. -TEST(DdsCApiTtConfiguration, DISABLED_SmallTtClearThenResetForSolve) +// Regression: on a Small TT, clear_tt() returns the pools and a following +// reset_for_solve() used to re-init over them, dereferencing null in +// TransTableS::init_tt(). The Large TT (the default) was never affected +// because TransTableL::reset_memory() already guarded the equivalent case. +// Reachable from the public API, so this covers the C++ and .NET paths too. +TEST(DdsCApiTtConfiguration, SmallTtClearThenResetForSolve) { DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); ASSERT_NE(ctx, nullptr); @@ -205,7 +195,7 @@ TEST(DdsCApiTtConfiguration, DISABLED_SmallTtClearThenResetForSolve) ASSERT_EQ(SolveReference(ctx), kExpectedTricks); dds_c_configure_tt(ctx, 0 /* Small */, 1, 2); dds_c_clear_tt(ctx); - dds_c_reset_for_solve(ctx); // <-- segfaults today + dds_c_reset_for_solve(ctx); EXPECT_EQ(SolveReference(ctx), kExpectedTricks); dds_c_destroy_solvercontext(ctx); From e9fdf1659bbe3396c7b40e20b9beb22598dcf913 Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Sun, 19 Jul 2026 18:11:44 +0100 Subject: [PATCH 05/21] Retarget the .NET binding onto the pure-C shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fourteen modern P/Invokes bound dds_* from dds_api.hpp, which the shared library does not export on Linux or macOS — every one of them would have thrown EntryPointNotFoundException there. They now bind the dds_c_* shim via EntryPoint, so the same managed code works on all three platforms. Managed method names are unchanged, so no call site in DDS.cs moves. The one exception is context creation, which takes scalars now that SolverConfig no longer crosses the ABI; SolverContext's constructor unpacks the config it already holds. TTKind needs no change: it is `enum TTKind : int` and marshals as the shim's int parameter. Also renames the library to "dds" (letting .NET's probing supply the lib prefix and per-OS extension), makes dds_log_append's UTF-8 marshalling explicit instead of relying on the platform-dependent CharSet.Ansi default, and deletes the commented-out calc_par and calc_par_from_table declarations — those are plain C++ with no extern "C" or DLLEXPORT, so they were never bindable on any platform. The 30 legacy flat-API P/Invokes are untouched; they already resolve. Note: DDS_Core.slnx still build-depends on solution/dds_native.vcxproj, which now emits a library name nothing loads. Retiring that project is deferred per the plan. Co-Authored-By: Claude Opus 4.8 --- dotnet/DDS_Core/DataModel/SolverContext.cs | 6 +- dotnet/DDS_Core/Native/DdsNative.cs | 68 +++++++++++----------- 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/dotnet/DDS_Core/DataModel/SolverContext.cs b/dotnet/DDS_Core/DataModel/SolverContext.cs index bdf29cc7..cb288263 100644 --- a/dotnet/DDS_Core/DataModel/SolverContext.cs +++ b/dotnet/DDS_Core/DataModel/SolverContext.cs @@ -17,7 +17,11 @@ public SolverContext() public SolverContext(SolverConfig config) { - Handle = DdsNative.dds_create_solvercontext(config) + // Unpacked into scalars: the native shim is pointer-only and + // POD-only, so SolverConfig never crosses the ABI boundary. + Handle = DdsNative.dds_create_solvercontext( (int) config.TTKind + , config.DefaultMemoryMB + , config.MaximumMemoryMB) ?? throw new InvalidOperationException("Failed to create SolverContext."); } diff --git a/dotnet/DDS_Core/Native/DdsNative.cs b/dotnet/DDS_Core/Native/DdsNative.cs index 8b9b7cf8..24294159 100644 --- a/dotnet/DDS_Core/Native/DdsNative.cs +++ b/dotnet/DDS_Core/Native/DdsNative.cs @@ -5,49 +5,67 @@ namespace DDS_Core.Native; internal static class DdsNative { - private const string DllName = "dds_native"; + // One native library for every platform. .NET's probing supplies the "lib" + // prefix and the per-OS extension, so this single name resolves + // libdds.dylib, libdds.so, and dds.dll. + private const string DllName = "dds"; + + // The modern context entry points below bind the pure-C shim (dds_c_*) + // rather than the reference-taking dds_* functions in dds_api.hpp. Only the + // shim is exported by the shared library on Linux and macOS; the managed + // method names are kept as-is so call sites are unaffected. #region ====== Version 3 specific methods ====== #region ===== Solver Context Management ====== - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_create_solvercontext_default", CallingConvention = CallingConvention.Cdecl)] internal static extern SolverContextHandle dds_create_solvercontext_default(); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - internal static extern SolverContextHandle dds_create_solvercontext(SolverConfig cfg); + // SolverConfig is passed as scalars: the shim is pointer-only and + // POD-only by design, so no struct crosses the ABI boundary. + [DllImport(DllName, EntryPoint = "dds_c_create_solvercontext", CallingConvention = CallingConvention.Cdecl)] + internal static extern SolverContextHandle dds_create_solvercontext( int ttKind + , int defMB + , int maxMB); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_destroy_solvercontext", CallingConvention = CallingConvention.Cdecl)] internal static extern void dds_destroy_solvercontext(IntPtr ctx); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + // TTKind is `enum TTKind : int`, which marshals as a plain int and + // matches the shim's int tt_kind parameter, so the managed + // signature is unchanged. + [DllImport(DllName, EntryPoint = "dds_c_configure_tt", CallingConvention = CallingConvention.Cdecl)] internal static extern void dds_configure_tt( SolverContextHandle ctx , TTKind kind , int defMB , int maxMB); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_resize_tt", CallingConvention = CallingConvention.Cdecl)] internal static extern void dds_resize_tt( SolverContextHandle ctx , int defMB , int maxMB); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_clear_tt", CallingConvention = CallingConvention.Cdecl)] internal static extern void dds_clear_tt(SolverContextHandle ctx); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_reset_for_solve", CallingConvention = CallingConvention.Cdecl)] internal static extern void dds_reset_for_solve(SolverContextHandle ctx); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_reset_best_moves_lite", CallingConvention = CallingConvention.Cdecl)] internal static extern void dds_reset_best_moves_lite(SolverContextHandle ctx); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] - internal static extern void dds_log_append( SolverContextHandle ctx, string msg); + // The shim documents msg as NUL-terminated UTF-8; be explicit rather + // than relying on the platform-dependent CharSet.Ansi default. + [DllImport(DllName, EntryPoint = "dds_c_log_append", CallingConvention = CallingConvention.Cdecl)] + internal static extern void dds_log_append( SolverContextHandle ctx + , [MarshalAs(UnmanagedType.LPUTF8Str)] string msg); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_log_clear", CallingConvention = CallingConvention.Cdecl)] internal static extern void dds_log_clear( SolverContextHandle ctx); #endregion #region ====== SolverContext methods ====== - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_solve_board", CallingConvention = CallingConvention.Cdecl)] public static extern int dds_solve_board( SolverContextHandle ctx , in Deal dl , int target @@ -56,40 +74,24 @@ public static extern int dds_solve_board( SolverContextHandle ctx , out FutureTricks fut); #region Call_dd - // [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - //public static extern int dds_calc_dd_table( in DdTableDeal table_deal - // , out DdTableResults table_results); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_calc_dd_table", CallingConvention = CallingConvention.Cdecl)] public static extern int dds_calc_dd_table( SolverContextHandle ctx , in DdTableDeal table_deal , out DdTableResults table_results); - // [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - //public static extern int dds_calc_dd_table_pbn( in DdTableDealPBN table_deal_pbn - // , out DdTableResults table_results); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_calc_dd_table_pbn", CallingConvention = CallingConvention.Cdecl)] public static extern int dds_calc_dd_table_pbn( SolverContextHandle ctx , in DdTableDealPBN table_deal_pbn , out DdTableResults table_results); #endregion #region Call_par - // [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - //public static extern int calc_par( in DdTableDeal table_deal - // , int vulnerable - // , out DdTableResults table_results - // , out ParResults par_results); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_calc_par", CallingConvention = CallingConvention.Cdecl)] public static extern int dds_calc_par( SolverContextHandle ctx , in DdTableDeal table_deal , int vulnerable , out DdTableResults table_results , out ParResults par_results); - - // [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - //public static extern int calc_par_from_table( in DdTableResults table_results - // , int vulnerable - // , out ParResults par_results); #endregion #endregion #endregion From a4c40c260954ef49a03572cb018aa2f811a72110 Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Sun, 19 Jul 2026 18:56:17 +0100 Subject: [PATCH 06/21] Add explicit native-library resolution via DDS_LIBRARY_PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default probing stays in force — that is what a NuGet package laying the library out under runtimes//native will rely on — but setting DDS_LIBRARY_PATH to a full path now overrides it, so tests and development builds can bind against a freshly-built bazel-bin/jni/libdds.dylib without installing anything. This is the .NET counterpart of the JVM binding's -Ddds.library.path. If the variable is set but the library will not load, the failure is raised naming the attempted path rather than falling through to probing: a typo in a test script should not surface later as a missing entry point. Registered from DdsNative's static constructor rather than a [ModuleInitializer]: the runtime guarantees a type initializer runs before that type's first P/Invoke, so it is equally safe while being lazy rather than eager — and CA2255 warns against module initializers in library code. The task sketched the module-initializer form; this is the same guarantee without the warning. Verified end to end on macOS/arm64: solving the reference board through SolverContext against the Bazel-built library returns 13 tricks, an unset variable falls back to probing, and a wrong one fails loudly. Co-Authored-By: Claude Opus 4.8 --- dotnet/DDS_Core/Native/DdsNative.cs | 4 ++ dotnet/DDS_Core/Native/DdsNativeResolver.cs | 62 +++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 dotnet/DDS_Core/Native/DdsNativeResolver.cs diff --git a/dotnet/DDS_Core/Native/DdsNative.cs b/dotnet/DDS_Core/Native/DdsNative.cs index 24294159..d0c260cb 100644 --- a/dotnet/DDS_Core/Native/DdsNative.cs +++ b/dotnet/DDS_Core/Native/DdsNative.cs @@ -10,6 +10,10 @@ internal static class DdsNative // libdds.dylib, libdds.so, and dds.dll. private const string DllName = "dds"; + // Runs before this type's first P/Invoke, so the DDS_LIBRARY_PATH override + // is always in place without consumers having to call anything. + static DdsNative() => DdsNativeResolver.Register(); + // The modern context entry points below bind the pure-C shim (dds_c_*) // rather than the reference-taking dds_* functions in dds_api.hpp. Only the // shim is exported by the shared library on Linux and macOS; the managed diff --git a/dotnet/DDS_Core/Native/DdsNativeResolver.cs b/dotnet/DDS_Core/Native/DdsNativeResolver.cs new file mode 100644 index 00000000..e5f342a8 --- /dev/null +++ b/dotnet/DDS_Core/Native/DdsNativeResolver.cs @@ -0,0 +1,62 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +namespace DDS_Core.Native; + +/// +/// Resolves the native DDS library for this assembly's P/Invokes. +/// +/// +/// +/// By default the runtime's own probing applies, which is what a NuGet package +/// laying the library out under runtimes/<rid>/native relies on. +/// Setting the DDS_LIBRARY_PATH environment variable to the full path of +/// a library file overrides that, which is how tests and development builds bind +/// against a freshly-built bazel-bin/jni/libdds.dylib without installing +/// anything. It is the .NET counterpart of the JVM binding's +/// -Ddds.library.path. +/// +/// +/// If DDS_LIBRARY_PATH is set but the library cannot be loaded, the +/// failure is surfaced with the attempted path rather than silently falling back +/// to probing: a typo in a test script should not present later as a missing +/// entry point. +/// +/// +internal static class DdsNativeResolver +{ + /// Environment variable holding an explicit path to the native library. + internal const string LibraryPathVariable = "DDS_LIBRARY_PATH"; + + /// + /// Registers the resolver. Called from 's static + /// constructor, which the runtime guarantees runs before that type's first + /// P/Invoke — so no explicit setup call is needed from consumers. A module + /// initializer would also work but runs eagerly at load time, which is both + /// more surprising in a library and flagged by CA2255. + /// + internal static void Register() + => NativeLibrary.SetDllImportResolver(typeof(DdsNative).Assembly, Resolve); + + private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) + { + // Never intercept imports belonging to anything but the DDS library. + if (!string.Equals(libraryName, "dds", StringComparison.Ordinal)) + return IntPtr.Zero; + + var explicitPath = Environment.GetEnvironmentVariable(LibraryPathVariable); + if (string.IsNullOrWhiteSpace(explicitPath)) + return IntPtr.Zero; // Fall back to the runtime's default probing. + + try + { + return NativeLibrary.Load(explicitPath); + } + catch (Exception ex) + { + throw new DllNotFoundException( + $"{LibraryPathVariable} is set to '{explicitPath}', but the native DDS " + + "library could not be loaded from there.", ex); + } + } +} From 25537c8517af7ab263ef2c6ff1e527fd5f1cd748 Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Sun, 19 Jul 2026 19:01:34 +0100 Subject: [PATCH 07/21] Declare the .NET projects AnyCPU rather than x64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both projects declared x64 while emitting an architecture-neutral assembly, so the declaration did not match what was built. Correcting the task's premise: this pin was NOT blocking Apple Silicon. only declares the valid platform list for the IDE/solution; it never set , so the assembly was already AnyCPU (PE machine 0x014c) and both `dotnet build` and `-p:Platform=AnyCPU` already succeeded on arm64. The change is therefore declarative — it stops the project claiming an architecture it does not target, and unrestricts the IDE configuration list — not an unblocking fix. Output paths verified unchanged: Directory.Build.props interpolates $(platform) into BaseIntermediateOutputPath, but Directory.Build.props is imported before the SDK defines Platform, so that property was already empty and the segment already collapsed. No empty or doubled path segments after the change. Co-Authored-By: Claude Opus 4.8 --- dotnet/DDS_Core/DDS_Core.csproj | 3 ++- dotnet/DDS_Core_Demo/DDS_Core_Demo.csproj | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dotnet/DDS_Core/DDS_Core.csproj b/dotnet/DDS_Core/DDS_Core.csproj index 35151565..72831453 100644 --- a/dotnet/DDS_Core/DDS_Core.csproj +++ b/dotnet/DDS_Core/DDS_Core.csproj @@ -4,7 +4,8 @@ net8.0 enable disable - x64 + + AnyCPU false ..\..\Build\bin\ True diff --git a/dotnet/DDS_Core_Demo/DDS_Core_Demo.csproj b/dotnet/DDS_Core_Demo/DDS_Core_Demo.csproj index ee83a962..ea2df6ef 100644 --- a/dotnet/DDS_Core_Demo/DDS_Core_Demo.csproj +++ b/dotnet/DDS_Core_Demo/DDS_Core_Demo.csproj @@ -5,7 +5,8 @@ net8.0 enable enable - x64 + + AnyCPU false ..\..\Build\bin\ From f277280c13be148dbc43980faf0a7ce7dbc6baf9 Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Sun, 19 Jul 2026 19:21:19 +0100 Subject: [PATCH 08/21] Add the managed test project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nineteen tests covering the retargeted binding, all passing on macOS/arm64 against the Bazel-built libdds.dylib. The layout tests carry the most weight. The managed structs had only ever been exercised against the MSVC ABI, and a mismatch on SysV or AArch64 corrupts results silently rather than throwing — so the smoke tests alone could not catch it. Expected sizes and offsets are derived from the C headers via offsetof/sizeof, not from running the C#, since asserting what the managed code already does would prove nothing. They pass, which is the first confirmation the layouts are correct off Windows. Between the smoke and lifecycle tests every one of the fourteen retargeted P/Invokes is exercised, so a missing EntryPoint fails here rather than in a consumer. Includes a managed regression for the Small-TT ClearTT/ResetForSolve crash fixed earlier in this branch, and a one-context-per-thread concurrency check. Targets net8.0 to match the library, with Major so the suite also runs where only a newer major runtime is installed (the Homebrew SDK case). This replaces the multi-targeting the task suggested: multi-targeting would have run both frameworks and still needed the roll-forward for the net8.0 pass, so it removed nothing. Where an 8.0 runtime exists, as on CI, the property has no effect. Output paths are inherited from Directory.Build.props rather than overridden: Build/int is gitignored where the SDK-default obj/ is not, and overriding BaseIntermediateOutputPath in the csproj comes too late for MSBuild (MSB3539). Note: DDS_Core.slnx does not build under `dotnet build` because it includes the C++ dds_native.vcxproj, which needs Visual Studio's MSBuild. That predates this change; the projects build individually, which is what CI and `dotnet test` use. Co-Authored-By: Claude Opus 4.8 --- .../DDS_Core.Tests/ContextLifecycleTests.cs | 125 ++++++++++++++++++ dotnet/DDS_Core.Tests/DDS_Core.Tests.csproj | 41 ++++++ dotnet/DDS_Core.Tests/LayoutTests.cs | 83 ++++++++++++ dotnet/DDS_Core.Tests/SmokeTests.cs | 64 +++++++++ dotnet/DDS_Core.Tests/TestDeals.cs | 54 ++++++++ dotnet/DDS_Core/DDS_Core.slnx | 6 + 6 files changed, 373 insertions(+) create mode 100644 dotnet/DDS_Core.Tests/ContextLifecycleTests.cs create mode 100644 dotnet/DDS_Core.Tests/DDS_Core.Tests.csproj create mode 100644 dotnet/DDS_Core.Tests/LayoutTests.cs create mode 100644 dotnet/DDS_Core.Tests/SmokeTests.cs create mode 100644 dotnet/DDS_Core.Tests/TestDeals.cs diff --git a/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs b/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs new file mode 100644 index 00000000..202a759e --- /dev/null +++ b/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs @@ -0,0 +1,125 @@ +using DDS_Core; + +namespace DDS_Core.Tests; + +/// +/// Covers the context-management entry points added to the C shim by this work: +/// config-based construction, TT configuration, the resets, logging, and +/// SafeHandle-driven disposal. Between these and , every +/// one of the fourteen retargeted P/Invokes is exercised — so a missing +/// EntryPoint fails here rather than in a consumer. +/// +public class ContextLifecycleTests +{ + [Theory] + [InlineData(TTKind.Small)] + [InlineData(TTKind.Large)] + public void ConstructedFromConfig_SolvesReferenceDeal(TTKind kind) + { + // Exercises dds_c_create_solvercontext, whose SolverConfig is unpacked + // into scalars at the ABI boundary. + using var ctx = new SolverContext(new SolverConfig(kind, 0, 0)); + + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks fut); + + Assert.Equal(TestDeals.ExpectedTricks, fut.Score[0]); + } + + [Fact] + public void TtReconfiguration_LeavesContextUsable() + { + using var ctx = new SolverContext(); + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks _); + + ctx.ConfigureTT(TTKind.Small, 1, 2); + ctx.ResizeTT(1, 2); + ctx.ClearTT(); + + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks fut); + Assert.Equal(TestDeals.ExpectedTricks, fut.Score[0]); + } + + [Fact] + public void Resets_LeaveContextUsable() + { + using var ctx = new SolverContext(); + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks _); + + ctx.ResetForSolve(); + ctx.ResetBestMovesLite(); + + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks fut); + Assert.Equal(TestDeals.ExpectedTricks, fut.Score[0]); + } + + /// + /// Regression for the Small-TT crash fixed alongside this binding work: + /// ClearTT() released the transposition-table pools and a following + /// ResetForSolve() re-initialised over them, faulting inside + /// TransTableS::init_tt(). Reachable from managed code exactly as written + /// here, so this is the .NET-side guard for that fix. + /// + [Fact] + public void SmallTt_ClearThenResetForSolve_DoesNotCrash() + { + using var ctx = new SolverContext(); + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks _); + + ctx.ConfigureTT(TTKind.Small, 1, 2); + ctx.ClearTT(); + ctx.ResetForSolve(); + + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks fut); + Assert.Equal(TestDeals.ExpectedTricks, fut.Score[0]); + } + + [Fact] + public void Logging_DoesNotDisturbSolving() + { + using var ctx = new SolverContext(); + + ctx.LogAppend("DDS_Core.Tests"); + ctx.LogAppend(string.Empty); + ctx.LogClear(); + + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks fut); + Assert.Equal(TestDeals.ExpectedTricks, fut.Score[0]); + } + + /// + /// Disposal must release the native context through SafeHandle without + /// faulting, and must be safe to repeat. + /// + [Fact] + public void Dispose_ReleasesHandleAndIsIdempotent() + { + var ctx = new SolverContext(); + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks _); + + ctx.Dispose(); + ctx.Dispose(); + + Assert.True(ctx.Handle.IsClosed || ctx.Handle.IsInvalid); + } + + /// + /// Contexts are single-threaded, so concurrent use means one context per + /// thread. This is the arrangement the docs prescribe; if it regressed, + /// multi-threaded consumers would corrupt results rather than fail loudly. + /// + [Fact] + public void OneContextPerThread_SolvesConcurrently() + { + const int threads = 4; + var results = new int[threads]; + + Parallel.For(0, threads, i => + { + using var ctx = new SolverContext(); + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks fut); + results[i] = fut.Score[0]; + }); + + Assert.All(results, r => Assert.Equal(TestDeals.ExpectedTricks, r)); + } +} diff --git a/dotnet/DDS_Core.Tests/DDS_Core.Tests.csproj b/dotnet/DDS_Core.Tests/DDS_Core.Tests.csproj new file mode 100644 index 00000000..f71123e0 --- /dev/null +++ b/dotnet/DDS_Core.Tests/DDS_Core.Tests.csproj @@ -0,0 +1,41 @@ + + + + + net8.0 + enable + enable + false + AnyCPU + + + Major + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/DDS_Core.Tests/LayoutTests.cs b/dotnet/DDS_Core.Tests/LayoutTests.cs new file mode 100644 index 00000000..4a66f95f --- /dev/null +++ b/dotnet/DDS_Core.Tests/LayoutTests.cs @@ -0,0 +1,83 @@ +using System.Runtime.InteropServices; +using DDS_Core; + +namespace DDS_Core.Tests; + +/// +/// Pins the managed struct layouts to the C structs in library/src/api/dll.h. +/// +/// +/// +/// These matter more than they look. The managed types were only ever exercised +/// against the MSVC ABI; a layout mismatch on SysV (Linux) or AArch64 (Apple +/// Silicon) corrupts results silently rather than throwing, so the smoke +/// tests alone cannot catch it. +/// +/// +/// The expected values are derived from the C headers — compiled with +/// offsetof/sizeof — not from running the C# side. Asserting what +/// the managed code already does would prove nothing. +/// +/// +public class LayoutTests +{ + // ---- Ground truth from library/src/api/dll.h (offsetof/sizeof, LP64) ---- + + [Fact] + public void Deal_MatchesNativeLayout() + { + Assert.Equal(96, Marshal.SizeOf()); + Assert.Equal(0, (int) Marshal.OffsetOf(nameof(Deal.Trump))); + Assert.Equal(4, (int) Marshal.OffsetOf(nameof(Deal.First))); + Assert.Equal(8, (int) Marshal.OffsetOf(nameof(Deal.CurrentTrickSuit))); + Assert.Equal(20, (int) Marshal.OffsetOf(nameof(Deal.CurrentTrickRank))); + Assert.Equal(32, (int) Marshal.OffsetOf(nameof(Deal.RemainingCards))); + } + + [Fact] + public void FutureTricks_MatchesNativeLayout() + { + Assert.Equal(216, Marshal.SizeOf()); + Assert.Equal(0, (int) Marshal.OffsetOf(nameof(FutureTricks.Nodes))); + Assert.Equal(4, (int) Marshal.OffsetOf(nameof(FutureTricks.NumberOfCards))); + Assert.Equal(8, (int) Marshal.OffsetOf(nameof(FutureTricks.Suit))); + Assert.Equal(60, (int) Marshal.OffsetOf(nameof(FutureTricks.Ranks))); + Assert.Equal(112, (int) Marshal.OffsetOf(nameof(FutureTricks.EqualGroups))); + Assert.Equal(164, (int) Marshal.OffsetOf(nameof(FutureTricks.Score))); + } + + [Fact] + public void DdTableDeal_MatchesNativeLayout() + => Assert.Equal(64, Marshal.SizeOf()); + + [Fact] + public void DdTableResults_MatchesNativeLayout() + => Assert.Equal(80, Marshal.SizeOf()); + + [Fact] + public void ParResults_MatchesNativeLayout() + => Assert.Equal(288, Marshal.SizeOf()); + + [Fact] + public void DdTableDealPBN_MatchesNativeLayout() + => Assert.Equal(80, Marshal.SizeOf()); + + /// + /// remainCards is [hand][suit], row-major with DDS_SUITS = 4 columns, so + /// element [hand][suit] lives at index hand * 4 + suit. The JVM binding + /// relies on the same arithmetic; if FourHands ever disagreed, deals would + /// be silently transposed rather than rejected. + /// + [Fact] + public void FourHands_IsRowMajorByHandThenSuit() + { + var hands = new FourHands(); + for (int hand = 0; hand < 4; hand++) + for (int suit = 0; suit < 4; suit++) + hands[hand, suit] = (uint) (hand * 4 + suit); + + var flat = hands.AsSpan(); + for (int i = 0; i < FourHands.SIZE; i++) + Assert.Equal((uint) i, flat[i]); + } +} diff --git a/dotnet/DDS_Core.Tests/SmokeTests.cs b/dotnet/DDS_Core.Tests/SmokeTests.cs new file mode 100644 index 00000000..42862189 --- /dev/null +++ b/dotnet/DDS_Core.Tests/SmokeTests.cs @@ -0,0 +1,64 @@ +using DDS_Core; + +namespace DDS_Core.Tests; + +/// +/// End-to-end solving through the retargeted binding — the .NET analogue of +/// DdsSmokeTest.java. These are what prove the dds_c_* entry +/// points actually resolve and marshal correctly on a non-Windows platform. +/// +public class SmokeTests +{ + [Fact] + public void SolveBoard_ReferenceDeal_TakesThirteenTricks() + { + using var ctx = new SolverContext(); + + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks fut); + + Assert.Equal(TestDeals.ExpectedTricks, fut.Score[0]); + } + + [Fact] + public void CalcDdTable_ReferenceDeal_MatchesExpectedTable() + { + using var ctx = new SolverContext(); + var deal = TestDeals.ReferenceTable(); + + ctx.CalcDdTable(deal, out DdTableResults results); + + for (int strain = 0; strain < 5; strain++) + for (int hand = 0; hand < 4; hand++) + Assert.Equal(TestDeals.ExpectedDdTable[strain][hand], results.ResultsTable[strain, hand]); + } + + /// + /// The PBN twin must agree with the binary form. This is the only PBN pair + /// on the modern layer, added to the shim by this work. + /// + [Fact] + public void CalcDdTable_PbnAgreesWithBinary() + { + using var ctx = new SolverContext(); + + ctx.CalcDdTable(TestDeals.ReferenceTable(), out DdTableResults binary); + + var pbnDeal = new DdTableDealPBN { Cards = TestDeals.ReferencePbn }; + ctx.CalcDdTable(pbnDeal, out DdTableResults pbn); + + for (int strain = 0; strain < 5; strain++) + for (int hand = 0; hand < 4; hand++) + Assert.Equal(binary.ResultsTable[strain, hand], pbn.ResultsTable[strain, hand]); + } + + [Fact] + public void CalcPar_ReferenceDeal_ProducesNonEmptyScore() + { + using var ctx = new SolverContext(); + + ctx.CalcPar(TestDeals.ReferenceTable(), 0 /* vulnerable: none */, + out DdTableResults _, out ParResults par); + + Assert.False(string.IsNullOrWhiteSpace(par.ParScores[0])); + } +} diff --git a/dotnet/DDS_Core.Tests/TestDeals.cs b/dotnet/DDS_Core.Tests/TestDeals.cs new file mode 100644 index 00000000..d8f58fd3 --- /dev/null +++ b/dotnet/DDS_Core.Tests/TestDeals.cs @@ -0,0 +1,54 @@ +using DDS_Core; + +namespace DDS_Core.Tests; + +/// +/// The shared reference board, matching DdsSmokeTest.java and +/// dds_c_api_test.cpp so the JVM, C++, and .NET bindings all assert +/// against one fixture. +/// +internal static class TestDeals +{ + /// Full 13-card holding bitmask (ranks 2..A). + internal const uint FullSuit = 0x7FFC; + + /// + /// North holds all spades, East all hearts, South all diamonds, West all + /// clubs. With spades trump and North to lead, North/South take all 13. + /// + internal const int ExpectedTricks = 13; + + /// res_table[strain][hand] for the reference board. + internal static readonly int[][] ExpectedDdTable = + [ + [13, 0, 13, 0], // spades + [0, 13, 0, 13], // hearts + [13, 0, 13, 0], // diamonds + [0, 13, 0, 13], // clubs + [0, 0, 0, 0], // no-trump + ]; + + /// The same board in PBN: spades.hearts.diamonds.clubs per hand. + internal const string ReferencePbn = + "N:AKQJT98765432... .AKQJT98765432.. ..AKQJT98765432. ...AKQJT98765432"; + + internal static Deal Reference() + { + var deal = new Deal { Trump = 0, First = 0, RemainingCards = new FourHands() }; + deal.RemainingCards[0, 0] = FullSuit; // North spades + deal.RemainingCards[1, 1] = FullSuit; // East hearts + deal.RemainingCards[2, 2] = FullSuit; // South diamonds + deal.RemainingCards[3, 3] = FullSuit; // West clubs + return deal; + } + + internal static DdTableDeal ReferenceTable() + { + var deal = new DdTableDeal { Cards = new FourHands() }; + deal.Cards[0, 0] = FullSuit; + deal.Cards[1, 1] = FullSuit; + deal.Cards[2, 2] = FullSuit; + deal.Cards[3, 3] = FullSuit; + return deal; + } +} diff --git a/dotnet/DDS_Core/DDS_Core.slnx b/dotnet/DDS_Core/DDS_Core.slnx index c30e54c4..49f37b06 100644 --- a/dotnet/DDS_Core/DDS_Core.slnx +++ b/dotnet/DDS_Core/DDS_Core.slnx @@ -14,4 +14,10 @@ + + + + From 71ff1fbbe7a6e7022724828e151d0c1d979f48fb Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Sun, 19 Jul 2026 19:31:06 +0100 Subject: [PATCH 09/21] Add .NET binding coverage to Linux, Windows, and macOS CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows is the reason this is not deferred to the packaging follow-up. It was the only platform where the .NET binding worked, via dds_native.dll and dds_api.hpp's dds_* symbols; after the shim retarget it binds dds_c_* in the Bazel-built dds.dll, and no existing test covers that path — the JNI smoke tests and export_set_test are target_compatible_with-excluded on Windows. macOS is included rather than skipped because it is the only CI coverage of the AArch64 ABI, which is exactly what the managed struct-layout tests guard: a mismatch there corrupts results silently instead of throwing. With Linux (SysV x86-64) and Windows (Win64), all three ABIs are now exercised. Each job resolves the native library through `bazel info bazel-bin` rather than the bazel-bin convenience symlink, which is configuration-dependent and moves when a different --config is used, and fails with a clear message if the library is absent rather than letting dotnet report a confusing missing-entry-point error. The SDK is pinned to 8.0.x to match what DDS_Core targets; the test project's RollForward only takes effect where no 8.0 runtime exists, which is not the case on these runners. Verified locally on macOS/arm64 by running the workflow's exact command sequence: 19/19 tests pass. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci_linux.yml | 20 +++++++++++++++++++- .github/workflows/ci_macos.yml | 19 +++++++++++++++++++ .github/workflows/ci_windows.yml | 27 +++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci_linux.yml b/.github/workflows/ci_linux.yml index b7d17bcb..1d4975f3 100644 --- a/.github/workflows/ci_linux.yml +++ b/.github/workflows/ci_linux.yml @@ -48,7 +48,25 @@ jobs: - name: Run all tests run: bazel test --verbose_failures //... - # 7️⃣ Upload test logs + # 7️⃣ .NET binding — build and test the managed wrapper against the shared + # library just built. Pinned to 8.0.x because that is what DDS_Core + # targets; the test project's RollForward only matters where no 8.0 + # runtime exists, which is not the case here. + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + # bazel-bin is a configuration-dependent convenience symlink, so ask Bazel + # for it rather than hardcoding a path that a different --config would move. + - name: Test .NET binding + run: | + bazel build --verbose_failures //jni:dds_shared + export DDS_LIBRARY_PATH="$(bazel info bazel-bin)/jni/libdds.so" + test -f "$DDS_LIBRARY_PATH" || { echo "native library not found at $DDS_LIBRARY_PATH"; exit 1; } + dotnet test dotnet/DDS_Core.Tests/ --verbosity normal + + # 8️⃣ Upload test logs - name: Upload test logs - Linux if: always() uses: actions/upload-artifact@v6 diff --git a/.github/workflows/ci_macos.yml b/.github/workflows/ci_macos.yml index 50c23e9b..b42c2c3e 100644 --- a/.github/workflows/ci_macos.yml +++ b/.github/workflows/ci_macos.yml @@ -47,6 +47,25 @@ jobs: - name: Run all tests run: bazelisk test --verbose_failures //... + # .NET binding — this job is the only CI coverage of the AArch64 ABI, which + # is what the managed struct-layout tests exist to guard: a layout mismatch + # there corrupts results silently rather than throwing. Linux covers SysV + # x86-64 and Windows covers Win64, so all three ABIs are exercised. + # Pinned to 8.0.x to match what DDS_Core targets. + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + # bazel-bin is a configuration-dependent convenience symlink, so ask Bazel + # for it rather than hardcoding a path that a different --config would move. + - name: Test .NET binding + run: | + bazelisk build --verbose_failures //jni:dds_shared + export DDS_LIBRARY_PATH="$(bazelisk info bazel-bin)/jni/libdds.dylib" + test -f "$DDS_LIBRARY_PATH" || { echo "native library not found at $DDS_LIBRARY_PATH"; exit 1; } + dotnet test dotnet/DDS_Core.Tests/ --verbosity normal + # Upload test logs - name: Upload test logs - macOS if: always() diff --git a/.github/workflows/ci_windows.yml b/.github/workflows/ci_windows.yml index a69cea9f..7d50cf20 100644 --- a/.github/workflows/ci_windows.yml +++ b/.github/workflows/ci_windows.yml @@ -56,6 +56,33 @@ jobs: - name: Run all tests run: bazel test --verbose_failures //... + # .NET binding — this is the platform the shim retarget can regress. + # Windows was previously the only place the binding worked, via + # dds_native.dll and dds_api.hpp's dds_* symbols; it now binds dds_c_* + # in the Bazel-built dds.dll, a path no other test covers (the JNI smoke + # tests and export_set_test are target_compatible_with-excluded here). + # Pinned to 8.0.x to match what DDS_Core targets. + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + # bazel-bin is a configuration-dependent convenience symlink, so ask Bazel + # for it rather than hardcoding a path that a different --config would move. + - name: Test .NET binding + shell: pwsh + run: | + bazel build --verbose_failures //jni:dds_shared + if ($LASTEXITCODE -ne 0) { exit 1 } + $binDir = bazel info bazel-bin + $env:DDS_LIBRARY_PATH = Join-Path $binDir "jni\dds.dll" + if (-not (Test-Path $env:DDS_LIBRARY_PATH)) { + Write-Host "native library not found at $env:DDS_LIBRARY_PATH" + exit 1 + } + dotnet test dotnet/DDS_Core.Tests/ --verbosity normal + if ($LASTEXITCODE -ne 0) { exit 1 } + # Upload test logs - name: Upload test logs - Windows if: always() From 2e63190ad112d30de50812cac35a806434ae81a5 Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Sun, 19 Jul 2026 19:36:36 +0100 Subject: [PATCH 10/21] Update docs and specs for the .NET binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three claims in dds-public-api.md were falsified by this work and are corrected: the shim is no longer "a thin subset ... no *PBN twins and no TT configure"; .NET no longer P/Invokes dds_* from dds_api.hpp; and the PBN pairing now does extend to the shim, via dds_c_calc_dd_table_pbn. jni-ffm-binding.md notes that the Windows DLLEXPORT superset is no longer relied on by any shipped binding, and that //jni:dds_shared serves .NET as well despite its location. Adds specs/dotnet-binding.md, a capability spec the repo lacked despite having one for the Python and JVM bindings. It records which ABI layer is bound and why, the single "dds" library name, DDS_LIBRARY_PATH resolution, SafeHandle ownership and one-context-per-thread, and the invariants the layout, smoke, and lifecycle tests enforce. Capability-level only — no per-method signatures, which stay in doxygen and docs/. docs/dotnet_interface.md gains a build-and-load section covering the Bazel target, per-OS artifacts, both load paths, and the advice to resolve via `bazel info bazel-bin` rather than the configuration-dependent symlink. It also states which native symbols are used, since binding dds_api.hpp is what previously made the wrapper Windows-only. Co-Authored-By: Claude Opus 4.8 --- docs/dotnet_interface.md | 66 +++++++++++++++++++++++++ docs/jni_interface.md | 16 +++--- specs/dds-public-api.md | 37 ++++++++------ specs/dotnet-binding.md | 103 +++++++++++++++++++++++++++++++++++++++ specs/jni-ffm-binding.md | 11 +++-- 5 files changed, 210 insertions(+), 23 deletions(-) create mode 100644 specs/dotnet-binding.md diff --git a/docs/dotnet_interface.md b/docs/dotnet_interface.md index 573f9381..75d24a4a 100644 --- a/docs/dotnet_interface.md +++ b/docs/dotnet_interface.md @@ -9,9 +9,16 @@ The library exposes the full DDS functionality through idiomatic `.Net` structur The goal is to provide a stable, fast, and fully documented .NET interface to the DDS engine. +It runs on **macOS, Linux, and Windows** from the same assembly — see +[Building and loading the native library](#building-and-loading-the-native-library). +The bindings mirror how the other language bindings work; see +[jni_interface.md](jni_interface.md), [python_interface.md](python_interface.md), +and [wasm_build.md](wasm_build.md). + --- ## Table of Contents +0. [Building and loading the native library](#building-and-loading-the-native-library) 1. [Introduction](#introduction) 2. [Legacy vs. Modern DDS API](#legacy-vs-modern-dds-api) 3. [Basic Usage](#basic-usage) @@ -39,6 +46,65 @@ The goal is to provide a stable, fast, and fully documented .NET interface to th --- +# Building and loading the native library + +`DDS_Core` is a managed wrapper; the solver itself lives in one self-contained +native library built by Bazel: + +```bash +bazel build //jni:dds_shared +``` + +| OS | Artifact | Location | +| ------- | -------------- | ---------------- | +| Linux | `libdds.so` | `bazel-bin/jni/` | +| macOS | `libdds.dylib` | `bazel-bin/jni/` | +| Windows | `dds.dll` | `bazel-bin/jni/` | + +Despite living under `//jni`, this is the shared native artifact for every +binding — the JVM one uses it too. + +## Finding the library at runtime + +The binding imports the library under the single name **`dds`**; .NET's probing +supplies the `lib` prefix and the per-OS extension, so one name resolves all +three artifacts above. There are two ways to point it at a library: + +- **Default probing** — place the library where the runtime already looks (next + to the application, or under `runtimes//native/` in a package). No + configuration needed. +- **`DDS_LIBRARY_PATH`** — set this environment variable to the *full path* of a + library file to override probing. This is the counterpart of the JVM binding's + `-Ddds.library.path`, and is how the tests bind against a freshly-built + library: + + ```bash + export DDS_LIBRARY_PATH="$(bazel info bazel-bin)/jni/libdds.dylib" + dotnet test dotnet/DDS_Core.Tests/ + ``` + + Use `bazel info bazel-bin` rather than the `bazel-bin` symlink: that symlink is + configuration-dependent and moves when you build with a different `--config`. + If the variable is set but the library cannot be loaded, the error names the + path you gave rather than reporting a missing entry point later. + +## Which native symbols are used + +The modern context API binds the pure-C shim (`dds_c_*`, from +`library/src/api/dds_c_api.h`); the legacy flat API binds `dll.h` directly. The +reference-taking `dds_*` functions in `dds_api.hpp` are deliberately **not** +used — they are not exported on Linux or macOS, and binding them is what +previously made this wrapper Windows-only. + +## Requirements + +.NET 8.0 or later (`.NET Framework` is not supported). If you build the test +project on a machine that has only a newer major runtime installed, note it sets +`Major` so it still runs; the library itself targets +`net8.0`. + +--- + # Introduction `DDS_Core` provides a managed .NET interface to the DDS engine. diff --git a/docs/jni_interface.md b/docs/jni_interface.md index 0d157248..32045e1d 100644 --- a/docs/jni_interface.md +++ b/docs/jni_interface.md @@ -68,12 +68,16 @@ and ctypes can all bind to: - The solver handle is opaque: `typedef void* DDS_C_SOLVER_CTX`. - Every struct is passed by pointer; no non-POD C++ type crosses the boundary. - -Shim entry points: `dds_c_create_solvercontext_default`, -`dds_c_destroy_solvercontext`, `dds_c_solve_board`, `dds_c_calc_dd_table`, -`dds_c_calc_par`. The flat legacy C API from `dll.h` (`SolveBoard`, -`CalcDDtable`, `GetDDSInfo`, `ErrorMessage`, …) is exported unchanged and is -also callable from FFM. + `SolverConfig` is decomposed into scalar arguments and `TTKind` crosses as an + `int`, so nothing is passed by value either. + +The shim covers the modern API's full surface: context lifecycle (default and +config-based creation, destroy), `dds_c_solve_board`, `dds_c_calc_dd_table` and +its `_pbn` twin, `dds_c_calc_par`, transposition-table configure/resize/clear, +both resets, and the logging passthroughs. The Java bindings here use a subset; +the .NET binding uses all of it ([dotnet_interface.md](dotnet_interface.md)). +The flat legacy C API from `dll.h` (`SolveBoard`, `CalcDDtable`, `GetDDSInfo`, +`ErrorMessage`, …) is exported unchanged and is also callable from FFM. ## Using the FFM bindings diff --git a/specs/dds-public-api.md b/specs/dds-public-api.md index da023ff0..325f0157 100644 --- a/specs/dds-public-api.md +++ b/specs/dds-public-api.md @@ -1,7 +1,7 @@ --- capability: dds-public-api owners: [api] -last-updated: 2026-07-18 +last-updated: 2026-07-19 --- # DDS Public API @@ -36,17 +36,21 @@ capability defines what crosses the boundary and promises to stay stable. header for the full set. 3. **Pure-C ABI shim** — `dds_c_api.h` (`dds_c_*`). Pointer-only, POD-only, opaque `void*` handle (`DDS_C_SOLVER_CTX`); no C++ types cross the boundary. - It forwards to layer 2. Today it is a thin subset (`dds_c_solve_board`, - `dds_c_calc_dd_table`, `dds_c_calc_par` plus context lifecycle) — **no - `*PBN` twins and no TT configure** in the shim. -- **Bindings pick different layers.** Java/FFM binds the shim (`dds_c_*` plus - `GetDDSInfo` from `dll.h`) — see [jni-ffm-binding](jni-ffm-binding.md). .NET - P/Invokes the modern `dds_*` symbols from `dds_api.hpp` - (`dotnet/DDS_Core/Native/DdsNative.cs`). Python wraps the C++ API via pybind11 - ([python-binding](python-binding.md)), not the C shim. There is no shipped - ctypes binding. The shim header is C-ABI but not C-includable (it pulls in - `dll.h`, which uses C++ trailing-return syntax) — bind to compiled symbols or - parse in C++ mode (jextract). + It forwards to layer 2 and now covers that layer's full surface: context + lifecycle (including config-based creation), `dds_c_solve_board`, + `dds_c_calc_dd_table` and its `_pbn` twin, `dds_c_calc_par`, TT + configure/resize/clear, both resets, and the logging passthroughs. + `SolverConfig` is decomposed into scalar arguments and `TTKind` crosses as + an `int`, so no struct is passed by value. +- **Bindings pick different layers.** Java/FFM and .NET both bind the shim + (`dds_c_*`) plus the flat `dll.h` API — see + [jni-ffm-binding](jni-ffm-binding.md) and [dotnet-binding](dotnet-binding.md). + .NET reaches the shim through `EntryPoint` on its P/Invokes + (`dotnet/DDS_Core/Native/DdsNative.cs`), keeping its managed method names. + Python wraps the C++ API via pybind11 ([python-binding](python-binding.md)), + not the C shim. There is no shipped ctypes binding. The shim header is C-ABI + but not C-includable (it pulls in `dll.h`, which uses C++ trailing-return + syntax) — bind to compiled symbols or parse in C++ mode (jextract). - **Handles are single-threaded.** One `DDS_SOLVER_CTX` / `DDS_C_SOLVER_CTX` per thread; the handle owns per-context solver state and its transposition table. Create → use → destroy. The legacy flat API manages global/threaded state via @@ -57,13 +61,17 @@ capability defines what crosses the boundary and promises to stay stable. entry points have a `*PBN` twin; both compute identical results from the same deal. On the modern C++ layer only `dds_calc_dd_table` has one (`dds_calc_dd_table_pbn`) — `dds_solve_board` and `dds_calc_par` do not. The - pairing does **not** extend to the C shim. + shim mirrors the modern layer exactly: `dds_c_calc_dd_table_pbn` is its only + PBN twin. Agreement between each pair is asserted by + `//library/tests:dds_c_api_test` and by the .NET smoke tests. - **The pinned binding export set is `dll.h` + `dds_c_api.h`.** On Linux/macOS the JNI shared library exports are constrained by `jni/version_script.lds` / `exported_symbols.lds` and checked by the export-set test. That is the *stable binding ABI*, not every `DLLEXPORT` symbol in the tree: `dds_api.hpp` also marks modern `dds_*` symbols `DLLEXPORT`, and on Windows (no `.lds`) the DLL exports that broader `DLLEXPORT` set. Details in [jni-ffm-binding](jni-ffm-binding.md). + Now that the shim covers the whole modern surface, no shipped binding depends + on that Windows-only surplus: all three platforms bind the same pinned set. ## Key entry points @@ -71,7 +79,8 @@ capability defines what crosses the boundary and promises to stay stable. narrative in `docs/legacy_c_api.md`. - `library/src/api/dds_api.hpp` — modern context C++ API. Narrative in `docs/c++_interface.md`; migration in `docs/api_migration.md`. -- `library/src/api/dds_c_api.h` — pure-C ABI shim (Java/FFM binding surface). +- `library/src/api/dds_c_api.h` — pure-C ABI shim (the Java/FFM and .NET binding + surface). Guarded by `//library/tests:dds_c_api_test`. - `library/src/api/{solve_board,calc_dd_table,calc_par}.hpp`, `PBN.h`, `portab.h`, `dds.h` — supporting public headers (`api_definitions`). - Build targets: `//library/src/api:dds_c_api`, `:api_definitions`, `//:dds` diff --git a/specs/dotnet-binding.md b/specs/dotnet-binding.md new file mode 100644 index 00000000..f5804684 --- /dev/null +++ b/specs/dotnet-binding.md @@ -0,0 +1,103 @@ +--- +capability: dotnet-binding +owners: [dotnet] +last-updated: 2026-07-19 +--- + +# .NET Binding (DDS_Core) + +> **Specs vs. doxygen / docs.** How to call the wrapper — the type-by-type API, +> legacy-vs-modern guidance, worked examples — is in `docs/dotnet_interface.md` +> and the XML doc comments on the types themselves. This spec records the +> cross-cutting contracts: which ABI layer is bound, how the native library is +> found, and the invariants the tests enforce. + +## Purpose + +This capability lets .NET consumers call the solver through an idiomatic managed +API — blittable structs, `SafeHandle` lifetimes, method overloading — on macOS, +Linux, and Windows from the same assembly. It exists so a .NET application gets +the solver without building the C++ or writing marshalling code. Like the JVM +binding it targets the pure-C shim from [dds-public-api](dds-public-api.md), not +the C++ API. + +## Behaviour & invariants + +> Per-type and per-method detail is in `DDS_Core`'s doc comments and +> `docs/dotnet_interface.md`. These are the whole-binding guarantees. + +- **Two ABI layers, one library.** The modern context entry points bind the + `dds_c_*` shim; the legacy flat API (`SolveBoard`, `CalcDDtable`, `Par`, + `Analyse*`, …) binds `dll.h` directly. Both come from the single native + library built by `//jni:dds_shared`. The binding does **not** use the + reference-taking `dds_*` symbols in `dds_api.hpp`: those are not exported on + Linux or macOS, so binding them made the wrapper Windows-only. +- **Managed names are decoupled from ABI names.** The shim is reached via + `EntryPoint` on each `DllImport`, so `DdsNative`'s method names — and every + call site in `DDS.cs` / `SolverContext.cs` — are independent of the C symbol + names. Renaming a shim export touches one attribute. +- **No struct crosses the boundary by value.** `SolverConfig` is unpacked into + scalar arguments at the P/Invoke and `TTKind` marshals as its underlying + `int`, matching the shim's pointer-only, POD-only contract. +- **One native library name: `dds`.** .NET's probing supplies the `lib` prefix + and per-OS extension, so a single `DllName` resolves `libdds.dylib`, + `libdds.so`, and `dds.dll`. +- **Library resolution is overridable.** A resolver registered from + `DdsNative`'s static constructor — which the runtime guarantees runs before + that type's first P/Invoke — honours the `DDS_LIBRARY_PATH` environment + variable, falling back to default probing when it is unset. When it is set but + unusable the failure names the attempted path rather than silently falling + through. This is the counterpart of the JVM binding's `-Ddds.library.path` and + is intended for tests and development, not deployment. +- **Struct layouts are pinned by tests, not by convention.** The managed structs + must match the C structs in `dll.h` byte for byte; a mismatch on SysV or + AArch64 corrupts results *silently* rather than throwing. `LayoutTests` + asserts sizes and field offsets against values derived from the C headers, and + `FourHands` indexing against the `hand * 4 + suit` row-major layout the other + bindings assume. +- **Solver contexts are single-threaded and deterministically released.** + `SolverContext` owns a `SolverContextHandle` (`SafeHandle`), so the native + context is freed on `Dispose` or finalization; disposal is idempotent. One + context per thread, as with every binding (see + [solver-context](solver-context.md)). +- **Integer status returns.** Entry points return `RETURN_*` codes as elsewhere + in the API; the wrapper converts failures to exceptions at its public surface. +- **All three ABIs are covered by CI.** The managed tests run on Linux (SysV + x86-64), Windows (Win64), and macOS (AArch64), each against the Bazel-built + shared library located via `bazel info bazel-bin`. Windows matters most: it is + the platform the shim retarget could regress, and the Bazel-built `dds.dll` + path is covered by no other test, since the JNI tests are + `target_compatible_with`-excluded there. + +## Key entry points + +- `dotnet/DDS_Core/Native/DdsNative.cs` — every P/Invoke; `DllName` and the + `EntryPoint` mapping onto `dds_c_*`. +- `dotnet/DDS_Core/Native/DdsNativeResolver.cs` — `DDS_LIBRARY_PATH` resolution. +- `dotnet/DDS_Core/DataModel/SolverContext.cs` — the modern managed API; + `Helpers/SolverContextHandle.cs` — `SafeHandle` ownership. +- `dotnet/DDS_Core/DataModel/`, `Helpers/` — the blittable structs and inline + array helpers whose layouts the tests pin. +- Native artifact: `//jni:dds_shared` (see [jni-ffm-binding](jni-ffm-binding.md)). +- Consumer guide: `docs/dotnet_interface.md`. +- Guarded by `dotnet/DDS_Core.Tests/` (`LayoutTests`, `SmokeTests`, + `ContextLifecycleTests`) and, on the native side, + `//library/tests:dds_c_api_test`. + +## Known gaps / non-goals + +- **No NuGet package yet.** Consumers build the project and supply the native + library themselves. RID-specific packaging (`runtimes//native/…`) and a + multi-platform package are a deliberate follow-up, mirroring how the jar + followed the JVM shared library. +- **`solution/dds_native.vcxproj` is no longer the shipped native artifact** but + is still present, and `DDS_Core.slnx` still build-depends on it. It builds a + differently-named DLL that the binding no longer loads; retiring it is + deferred. As a consequence `DDS_Core.slnx` does not build under `dotnet + build` — it includes a C++ project needing Visual Studio's MSBuild. The + individual projects build fine, which is what CI uses. +- **The test project targets `net8.0` with `RollForward` set to `Major`**, so it + also runs where only a newer major runtime is installed. CI pins an 8.0 SDK, + where the property has no effect. +- Per-type API documentation is intentionally not duplicated here; it lives in + `docs/dotnet_interface.md` and the types' doc comments. diff --git a/specs/jni-ffm-binding.md b/specs/jni-ffm-binding.md index 6b2f5ac0..c98866f3 100644 --- a/specs/jni-ffm-binding.md +++ b/specs/jni-ffm-binding.md @@ -1,7 +1,7 @@ --- capability: jni-ffm-binding owners: [jni] -last-updated: 2026-07-18 +last-updated: 2026-07-19 --- # JVM Binding (Foreign Function & Memory) @@ -30,7 +30,9 @@ shim from [dds-public-api](dds-public-api.md), not the C++ API. links every internal sub-library statically so `System.loadLibrary("dds")` needs exactly one file. Per-OS name: `dds.dll` / `libdds.dylib` / `libdds.so`. On Unix the export set is the stable C ABI (`dll.h` + `dds_c_*`); on Windows - it is broader — see the next bullet. + it is broader — see the next bullet. Despite living under `//jni`, this target + is the shared native artifact for the .NET binding too + ([dotnet-binding](dotnet-binding.md)). - **The exported ABI is pinned by checked-in export lists on Unix.** Linux links with `version_script.lds` (`-Wl,--version-script`), macOS with `exported_symbols.lds` (`-Wl,-exported_symbols_list`). Those `.lds` files are @@ -40,7 +42,10 @@ shim from [dds-public-api](dds-public-api.md), not the C++ API. parser is unit-tested by `gen_export_lists_test`. **Windows has no `.lds` branch:** the DLL exports whatever is marked `DLLEXPORT`, which is a **superset** of the Unix list (it also includes the modern `dds_*` context API - from `dds_api.hpp`). See [dds-public-api](dds-public-api.md). + from `dds_api.hpp`). No shipped binding relies on that surplus any more: the + shim now covers the whole modern surface, so the JVM and .NET bindings bind + the same pinned set on every platform. See + [dds-public-api](dds-public-api.md) and [dotnet-binding](dotnet-binding.md). - **Bindings are hand-written, not jextract-generated.** `//jni:dds_ffm` (`java_library`, `java/org/dds/ffm/Dds.java`) declares the struct `MemoryLayout`s and `Linker` downcall handles for the `dds_c_*` shim plus From 22d4cc4f12ac77eb82ab615b0e129ca0b1712c22 Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Mon, 20 Jul 2026 15:58:24 +0100 Subject: [PATCH 11/21] Fix heap-use-after-free in clear_tt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clear_tt() returned the transposition table's memory but kept the instance. SearchContext::trans_table() only checks whether tt_ is non-null, so it handed that husk straight back and the next lookup read freed pools. Caught by ASan on Linux CI: allocated: TransTable{L,S}::make_tt freed: TransTable{L,S}::return_all_memory <- clear_tt() read: TransTable{L,S}::lookup <- next solve Disposing the instance instead makes the documented "recreates lazily on demand" behaviour real: tt_ becomes null, so the next trans_table() rebuilds from the owner's config. Nothing is lost, since the kind and memory limits live in SolverContext::cfg_ rather than in the TT instance, and the destructor returns the memory either way. Disposal also keeps the fix off the hot path — ab_search calls trans_table() per lookup, so re-checking allocation state there would have cost more than it saved. This is pre-existing and independent of the .NET work: reproduced through the reference-taking C++ API as dds_clear_tt() followed by dds_solve_board(), with no dds_c_* call involved. It affects the default Large TT, so the existing .NET binding has the same latent fault via SolverContext.ClearTT() then SolveBoard(). Note this supersedes the TransTableS::reset_memory guard added earlier in this branch as the active fix for that path: with the instance disposed, maybe_trans_table() returns null and reset_for_solve() no longer reaches a memory-less table. That guard is left in place as defence in depth, mirroring the one TransTableL::reset_memory already had. Verified: ASan and TSan clean across //library/tests/..., 59/59 Bazel tests, 19/19 .NET tests. Co-Authored-By: Claude Opus 4.8 --- library/src/solver_context/solver_context.cpp | 14 ++++++++++++-- library/src/solver_context/solver_context.hpp | 9 +++++++-- library/tests/dds_c_api_test.cpp | 17 +++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/library/src/solver_context/solver_context.cpp b/library/src/solver_context/solver_context.cpp index 8b27ef6d..0ba575c0 100644 --- a/library/src/solver_context/solver_context.cpp +++ b/library/src/solver_context/solver_context.cpp @@ -200,8 +200,18 @@ auto SolverContext::clear_tt() const -> void #ifdef DDS_UTILITIES_LOG utilities().log_append("tt:clear"); #endif - if (auto* tt = search_.maybe_trans_table()) - tt->return_all_memory(); + // Dispose the instance rather than calling return_all_memory() on it. Both + // free the pools — the TT destructor returns all memory — but returning the + // memory while keeping the object leaves a husk whose pool pointers dangle, + // and SearchContext::trans_table() hands that husk straight back because it + // only checks whether tt_ is non-null. The next lookup then reads freed + // memory (ASan: heap-use-after-free in TransTable{L,S}::lookup). + // + // Disposing instead makes the documented "recreates lazily on demand" + // behaviour real: tt_ becomes null, so the next trans_table() rebuilds from + // the owner's config. Nothing is lost, because the kind and memory limits + // live in SolverContext::cfg_, not in the TT instance. + const_cast(this)->search_.dispose_trans_table(); } auto SolverContext::resize_tt(int defMB, int maxMB) const -> void diff --git a/library/src/solver_context/solver_context.hpp b/library/src/solver_context/solver_context.hpp index 7ba130e6..acd209d2 100644 --- a/library/src/solver_context/solver_context.hpp +++ b/library/src/solver_context/solver_context.hpp @@ -215,9 +215,14 @@ class SolverContext */ auto reset_best_moves_lite() const -> void; /** - * @brief Return all TT memory to the system without destroying the TT. + * @brief Return all TT memory to the system. + * + * Disposes the TT instance; the configured kind and memory limits persist on + * the context, so the next use recreates an empty table from them. Keeping a + * memory-less instance alive instead would leave dangling pool pointers for + * the next lookup to read. */ - auto clear_tt() const -> void; // Calls ReturnAllMemory() + auto clear_tt() const -> void; /** * @brief Resize TT memory defaults and limits in-place if TT exists. */ diff --git a/library/tests/dds_c_api_test.cpp b/library/tests/dds_c_api_test.cpp index dc51322f..4258bc33 100644 --- a/library/tests/dds_c_api_test.cpp +++ b/library/tests/dds_c_api_test.cpp @@ -168,6 +168,23 @@ TEST(DdsCApiTtConfiguration, ContextRemainsUsableAfterReconfiguration) dds_c_destroy_solvercontext(ctx); } +// Regression: clear_tt() used to return the TT's memory while keeping the +// instance, so the next lookup read freed pools (ASan: heap-use-after-free in +// TransTableL::lookup_suit). This is the default-configuration path — no TT +// kind switch involved — and is reachable from every binding as +// clear_tt() followed by a solve. +TEST(DdsCApiTtConfiguration, ClearTtThenSolveOnDefaultTt) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + ASSERT_EQ(SolveReference(ctx), kExpectedTricks); + dds_c_clear_tt(ctx); + EXPECT_EQ(SolveReference(ctx), kExpectedTricks); + + dds_c_destroy_solvercontext(ctx); +} + TEST(DdsCApiResets, ResetsLeaveContextUsable) { DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); From e58416ec61f1b812f174c151f94ca2609492d5cd Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Mon, 20 Jul 2026 19:36:24 +0100 Subject: [PATCH 12/21] Route clear_tt through dispose_trans_table clear_tt() performed the disposal with a raw const_cast on search_, bypassing SolverContext::dispose_trans_table() twelve lines above. That skipped the "tt:dispose" log entry and the tt_disposes stats counter, so the counter under-reported and the log trace no longer recorded that the TT went away. configure_tt() already calls dispose_trans_table() for the same operation; the two disposal paths are now consistent. Co-Authored-By: Claude Opus 4.8 --- library/src/solver_context/solver_context.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/src/solver_context/solver_context.cpp b/library/src/solver_context/solver_context.cpp index 0ba575c0..b64c000b 100644 --- a/library/src/solver_context/solver_context.cpp +++ b/library/src/solver_context/solver_context.cpp @@ -211,7 +211,7 @@ auto SolverContext::clear_tt() const -> void // behaviour real: tt_ becomes null, so the next trans_table() rebuilds from // the owner's config. Nothing is lost, because the kind and memory limits // live in SolverContext::cfg_, not in the TT instance. - const_cast(this)->search_.dispose_trans_table(); + dispose_trans_table(); } auto SolverContext::resize_tt(int defMB, int maxMB) const -> void From 399194549eedda492ec713a7e9c05be5c051f119 Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Mon, 20 Jul 2026 19:40:52 +0100 Subject: [PATCH 13/21] Amend solver-context spec for the new clear_tt semantics The heap-use-after-free fix changed clear_tt() from return_all_memory() on the live TT to disposing the instance, but the spec still documented the old behaviour in two places -- including an explicit "reuse after clear_tt() is unsafe" warning and "No test guards this today", both of which are now false. A caller reading the spec would work around a hazard that no longer exists and treat a supported path as broken. Co-Authored-By: Claude Opus 4.8 --- specs/solver-context.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/specs/solver-context.md b/specs/solver-context.md index c2bf5c08..6ab48b08 100644 --- a/specs/solver-context.md +++ b/specs/solver-context.md @@ -1,7 +1,7 @@ --- capability: solver-context owners: [solver_context] -last-updated: 2026-07-18 +last-updated: 2026-07-20 --- # Solver Context @@ -48,19 +48,19 @@ the opaque handle. See [dds-public-api](dds-public-api.md). `reset_for_solve()` clears a subset of search state and resets TT memory (`ResetReason::FreeMemory`) while preserving the allocation for reuse; `reset_best_moves_lite()` clears only best-move ranks (hot per-iteration path); - `clear_tt()` calls `return_all_memory()` on the existing TT object; - `dispose_trans_table()` destroys the TT immediately. -- **`clear_tt()` does not leave a reusable table.** It keeps the `unique_ptr` - alive but frees the table's storage, and nothing re-runs `make_tt()`: - `SearchContext::trans_table()` returns early whenever `tt_` is non-null, and - `TransTable::init()` only fills aggregate lookup arrays — it does not - reallocate. Reusing the context after `clear_tt()` without - `dispose_trans_table()` (or recreating the context) is **unsafe**: the next - `lookup`/`add` can touch freed memory. Do not treat this as a quiet "dead - cache" — `TransTableS` does not gate `lookup`/`add` on `tt_in_use_`, and - `TransTableL::return_all_memory()` does not put the table into a reliably - inert state. Use `dispose_trans_table()` when the next solve should get a - fresh table. No test guards this today. + `clear_tt()` disposes the TT instance; `dispose_trans_table()` destroys the + TT immediately. +- **`clear_tt()` leaves the context reusable.** It disposes the TT instance + rather than calling `return_all_memory()` on it, so `tt_` becomes null and + the next `SearchContext::trans_table()` rebuilds an empty table lazily. The + configured kind and memory limits survive, because they live in + `SolverContext::cfg_` and not in the TT instance. Calling `return_all_memory()` + while keeping the object was the earlier behaviour and was **unsafe**: it left + a husk whose pool pointers dangled, which `trans_table()` handed straight back + because it only checks whether `tt_` is non-null — the next `lookup`/`add` + read freed memory. `clear_tt()` and `dispose_trans_table()` now differ only in + their log/stats trace. Guarded by `//library/tests:dds_c_api_test` + (`DdsCApiTtConfiguration.ClearTtThenSolveOnDefaultTt`). - **Hot-path facades are value-typed and inline-friendly, with different holds.** `MoveGenContext` holds a raw `ThreadData*` so `move_gen()` can return a value-typed facade without an atomic `shared_ptr` bump on every call. From d562d06878e4c8cd671c19dcab4b4dc200d31785 Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Mon, 20 Jul 2026 20:03:49 +0100 Subject: [PATCH 14/21] Cover the TransTableS reset guard with a test that reaches it Two tests claimed to guard TransTableS::reset_memory()'s tt_in_use_ early return, but neither reached it: clear_tt() now disposes the TT, so reset_for_solve() sees no table and never calls reset_memory(). Both would have passed with the guard reverted -- verified by temporarily removing it. Add a direct unit test driving TransTableS through make_tt/return_all_memory/reset_memory, which segfaults without the guard (verified), and reword the two indirect tests plus the guard's comment to describe what they actually cover. Co-Authored-By: Claude Opus 4.8 --- .../DDS_Core.Tests/ContextLifecycleTests.cs | 13 +++---- library/src/trans_table/trans_table_s.cpp | 9 +++-- library/tests/dds_c_api_test.cpp | 10 +++--- .../tests/trans_table/trans_table_s_test.cpp | 36 ++++++++++++++++++- 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs b/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs index 202a759e..40630f14 100644 --- a/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs +++ b/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs @@ -53,14 +53,15 @@ public void Resets_LeaveContextUsable() } /// - /// Regression for the Small-TT crash fixed alongside this binding work: - /// ClearTT() released the transposition-table pools and a following - /// ResetForSolve() re-initialised over them, faulting inside - /// TransTableS::init_tt(). Reachable from managed code exactly as written - /// here, so this is the .NET-side guard for that fix. + /// A Small-TT context survives ClearTT() followed by ResetForSolve() and + /// still solves. ClearTT() disposes the transposition table, so the + /// following solve rebuilds one lazily from the context's configuration. + /// This exercises the managed TT-lifecycle path; the native + /// TransTableS::reset_memory() guard is covered by + /// //library/tests/trans_table:trans_table. /// [Fact] - public void SmallTt_ClearThenResetForSolve_DoesNotCrash() + public void SmallTt_ClearThenResetForSolve_StillSolves() { using var ctx = new SolverContext(); ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks _); diff --git a/library/src/trans_table/trans_table_s.cpp b/library/src/trans_table/trans_table_s.cpp index eaa57240..73f92ab2 100644 --- a/library/src/trans_table/trans_table_s.cpp +++ b/library/src/trans_table/trans_table_s.cpp @@ -346,9 +346,12 @@ auto TransTableS::reset_memory( // Nothing to reset when the pools have been returned: return_all_memory() // frees pw_/pn_/pl_ and clears tt_in_use_, and make_tt() reallocates lazily // before the next lookup. Without this guard init_tt() below dereferences - // the freed pools (pw_[0]) and segfaults — reachable from the public API as - // configure_tt(Small) -> clear_tt() -> reset_for_solve(). TransTableL's - // reset_memory() already guards the equivalent case with `pool_ == nullptr`. + // the freed pools (pw_[0]) and segfaults. TransTableL's reset_memory() + // already guards the equivalent case with `pool_ == nullptr`. + // + // Defensive: SolverContext::clear_tt() disposes the TT instance rather than + // returning its memory, so no public-API sequence reaches this today. It is + // covered directly by TransTableSMemoryTest.ResetAfterReturnAllMemoryIsInert. if (!tt_in_use_) return; diff --git a/library/tests/dds_c_api_test.cpp b/library/tests/dds_c_api_test.cpp index 4258bc33..fb64cc38 100644 --- a/library/tests/dds_c_api_test.cpp +++ b/library/tests/dds_c_api_test.cpp @@ -199,11 +199,11 @@ TEST(DdsCApiResets, ResetsLeaveContextUsable) dds_c_destroy_solvercontext(ctx); } -// Regression: on a Small TT, clear_tt() returns the pools and a following -// reset_for_solve() used to re-init over them, dereferencing null in -// TransTableS::init_tt(). The Large TT (the default) was never affected -// because TransTableL::reset_memory() already guarded the equivalent case. -// Reachable from the public API, so this covers the C++ and .NET paths too. +// A Small-TT context survives clear_tt() followed by reset_for_solve() and +// still solves. clear_tt() disposes the TT, so reset_for_solve() finds no +// table and the following solve rebuilds one lazily from the context's config. +// The TransTableS::reset_memory() guard is not on this path — it is covered +// directly by //library/tests/trans_table:trans_table. TEST(DdsCApiTtConfiguration, SmallTtClearThenResetForSolve) { DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); diff --git a/library/tests/trans_table/trans_table_s_test.cpp b/library/tests/trans_table/trans_table_s_test.cpp index f0e95008..a0239304 100644 --- a/library/tests/trans_table/trans_table_s_test.cpp +++ b/library/tests/trans_table/trans_table_s_test.cpp @@ -4,7 +4,7 @@ // Include DDS types first #include -// No TransTable dependencies needed in this file; remove legacy forward declarations. +#include namespace dds_test { @@ -33,6 +33,40 @@ static void CreateTestWinRanks(unsigned short win_ranks[DDS_SUITS]) { win_ranks[3] = 0x8888; // Clubs } +// Regression: reset_memory() after return_all_memory() must be inert. +// return_all_memory() frees pw_/pn_/pl_ and clears tt_in_use_; without the +// guard in TransTableS::reset_memory(), init_tt() dereferences the freed pools +// (pw_[0]) and segfaults. TransTableL::reset_memory() already guards the +// equivalent case with `pool_ == nullptr`. +TEST(TransTableSMemoryTest, ResetAfterReturnAllMemoryIsInert) { + TransTableS tt; + + tt.set_memory_maximum(1); + tt.make_tt(); + tt.return_all_memory(); + + // The guard under test. Without it this call segfaults: init_tt() + // dereferences pw_[0], which return_all_memory() has already freed. + // Reaching this line at all is the regression assertion. + tt.reset_memory(ResetReason::FreeMemory); + + // The table is usable again once the pools are reallocated. + tt.make_tt(); + + int handLookup[15][15]; + CreateBasicHandLookup(handLookup); + tt.init(handLookup); + tt.reset_memory(ResetReason::NewDeal); + + unsigned short aggrTarget[DDS_SUITS]; + CreateTestAggrTarget(aggrTarget); + int hand_dist[4] = {0, 0, 0, 0}; + bool lowerFlag = false; + + // Nothing was added, so the rebuilt table must miss rather than crash. + EXPECT_EQ(tt.lookup(1, 0, aggrTarget, hand_dist, 0, lowerFlag), nullptr); +} + // Test that verifies DDS constants are available TEST(TransTableSBasicTest, DDSConstantsAvailable) { // Verify that basic DDS constants are accessible From dc6d4be106511bc510b34e51b2c4d062ebd7a16d Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Mon, 20 Jul 2026 20:04:35 +0100 Subject: [PATCH 15/21] Correct the dds_c_api_test dependency comment The comment claimed the //library/src:dds edge retains an __attribute__((constructor)) that initializes static solver memory. All three parts were wrong: on Windows dds.cpp takes the DllMain branch, and DllMain never runs for a statically-linked cc_test; a plain deps edge does not force retention of an unreferenced object file (that needs alwayslink); and dds_c_api already depends on //library/src:dds, so the edge changes nothing. The cited failure mode belongs to the TransTableS reset guard, which is unrelated to static-memory init. The test passes because SolverContext(SolverConfig) allocates its own ThreadData. Say that instead. Co-Authored-By: Claude Opus 4.8 --- library/tests/BUILD.bazel | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/library/tests/BUILD.bazel b/library/tests/BUILD.bazel index 28277318..89e3f696 100644 --- a/library/tests/BUILD.bazel +++ b/library/tests/BUILD.bazel @@ -63,11 +63,12 @@ cc_test( # catch-all wrappers that exist only at that boundary and would be bypassed by # calling the reference-taking dds_* functions directly. # -# Depends on //library/src:dds explicitly, not just transitively through -# dds_c_api: dds.cpp carries the __attribute__((constructor)) that initializes -# static solver memory, and without a direct edge the linker drops that object -# file — leaving TransTableS::init_tt() to dereference null on the first solve -# after a TT-kind switch. The shipped //jni:dds_shared already depends on both. +# //library/src:dds is listed explicitly for readability; //library/src/api:dds_c_api +# already depends on it. The test needs no static-memory initialization: +# SolverContext(SolverConfig) allocates its own ThreadData, so every entry point +# here is driven from context-owned state rather than InitializeStaticMemory(). +# A test that does need it should call InitializeStaticMemory() in a fixture, as +# library/tests/system/context_tt_facade_test.cpp does. cc_test( name = "dds_c_api_test", srcs = ["dds_c_api_test.cpp"], From 3b1a20986fbb3e4e874263012c4b7ccda76b07cd Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Mon, 20 Jul 2026 20:16:42 +0100 Subject: [PATCH 16/21] Surface native failures in Release, not only in DEBUG ThrowIfError carried [Conditional("DEBUG")], which elides every call site in a Release build. In the configuration consumers actually ship, SolveBoard/CalcDdTable/CalcPar returned the raw RETURN_* code and never threw -- the opposite of what specs/dotnet-binding.md promises. Drop the attribute so the documented contract holds in every build, and add a test driving RETURN_SOLNS_WRONG_HI that fails in Release if the check is ever made conditional again. The spec bullet now says so explicitly. Note this is consumer-visible: code that previously inspected return codes in Release will now see exceptions on failure paths. Co-Authored-By: Claude Opus 4.8 --- dotnet/DDS_Core.Tests/ContextLifecycleTests.cs | 15 +++++++++++++++ dotnet/DDS_Core/DataModel/SolverContext.cs | 9 ++++++--- specs/dotnet-binding.md | 5 ++++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs b/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs index 40630f14..04774ec8 100644 --- a/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs +++ b/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs @@ -25,6 +25,21 @@ public void ConstructedFromConfig_SolvesReferenceDeal(TTKind kind) Assert.Equal(TestDeals.ExpectedTricks, fut.Score[0]); } + /// + /// Failures must throw at the public surface in every build configuration, + /// not only in DEBUG. `solutions = 4` is out of range and the solver returns + /// RETURN_SOLNS_WRONG_HI (-9); if ThrowIfError is ever made conditional + /// again, this test fails in Release. + /// + [Fact] + public void SolveBoard_WithInvalidSolutions_Throws() + { + using var ctx = new SolverContext(); + + Assert.Throws( + () => ctx.SolveBoard(TestDeals.Reference(), -1, 4, 1, out FutureTricks _)); + } + [Fact] public void TtReconfiguration_LeavesContextUsable() { diff --git a/dotnet/DDS_Core/DataModel/SolverContext.cs b/dotnet/DDS_Core/DataModel/SolverContext.cs index cb288263..218399d8 100644 --- a/dotnet/DDS_Core/DataModel/SolverContext.cs +++ b/dotnet/DDS_Core/DataModel/SolverContext.cs @@ -1,5 +1,4 @@ -using System.Diagnostics; -using DDS_Core.Helpers; +using DDS_Core.Helpers; using DDS_Core.Native; namespace DDS_Core; @@ -141,7 +140,11 @@ public int CalcPar( in DdTableDeal table_deal #endregion #region private methods - [Conditional("DEBUG")] + // Deliberately not [Conditional("DEBUG")]: that elided every call site in + // Release, so the configuration consumers actually ship returned the raw + // RETURN_* code and never threw, contradicting the documented contract in + // specs/dotnet-binding.md. The check is one integer compare on a call that + // has just run a search. private static void ThrowIfError(int result, string functionName) { if (result != (int)SolveBoardResult.NoFault) diff --git a/specs/dotnet-binding.md b/specs/dotnet-binding.md index f5804684..e51b2006 100644 --- a/specs/dotnet-binding.md +++ b/specs/dotnet-binding.md @@ -61,7 +61,10 @@ the C++ API. context per thread, as with every binding (see [solver-context](solver-context.md)). - **Integer status returns.** Entry points return `RETURN_*` codes as elsewhere - in the API; the wrapper converts failures to exceptions at its public surface. + in the API; the wrapper converts failures to exceptions at its public surface, + in every build configuration. The check must not be made conditional on + `DEBUG` — that silently downgrades Release consumers to unchecked return + codes, which is the opposite of what this bullet promises. - **All three ABIs are covered by CI.** The managed tests run on Linux (SysV x86-64), Windows (Win64), and macOS (AArch64), each against the Bazel-built shared library located via `bazel info bazel-bin`. Windows matters most: it is From f338bf91da83467a993f15771616e074d5849fdd Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Mon, 20 Jul 2026 20:26:34 +0100 Subject: [PATCH 17/21] Detect failed context creation via IsInvalid, not a null check The `?? throw` on both constructors was dead code. A P/Invoke returning a SafeHandle-derived type never yields null -- the marshaller constructs an instance and stores whatever pointer came back -- so when dds_c_create_solvercontext returns NULL the caller got a handle with IsInvalid == true, the throw never fired, and every subsequent call passed IntPtr.Zero into the shim to be null-guarded into RETURN_UNKNOWN_FAULT. Test the handle instead, disposing the failed one, and assert on the successful path that construction yields a live pointer. Co-Authored-By: Claude Opus 4.8 --- .../DDS_Core.Tests/ContextLifecycleTests.cs | 19 ++++++++++++ dotnet/DDS_Core/DataModel/SolverContext.cs | 29 +++++++++++++++---- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs b/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs index 04774ec8..c4dbd810 100644 --- a/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs +++ b/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs @@ -25,6 +25,25 @@ public void ConstructedFromConfig_SolvesReferenceDeal(TTKind kind) Assert.Equal(TestDeals.ExpectedTricks, fut.Score[0]); } + /// + /// A successfully created context must hold a valid native handle. The + /// creation guard cannot test the reference for null — the SafeHandle + /// marshaller never returns one — so it tests IsInvalid, and this pins that + /// the successful path actually produces a live pointer rather than + /// IntPtr.Zero silently sailing through. + /// + [Theory] + [InlineData(TTKind.Small)] + [InlineData(TTKind.Large)] + public void Construction_YieldsValidHandle(TTKind kind) + { + using var fromConfig = new SolverContext(new SolverConfig(kind, 0, 0)); + Assert.False(fromConfig.Handle.IsInvalid); + + using var fromDefault = new SolverContext(); + Assert.False(fromDefault.Handle.IsInvalid); + } + /// /// Failures must throw at the public surface in every build configuration, /// not only in DEBUG. `solutions = 4` is out of range and the solver returns diff --git a/dotnet/DDS_Core/DataModel/SolverContext.cs b/dotnet/DDS_Core/DataModel/SolverContext.cs index 218399d8..87d0f81c 100644 --- a/dotnet/DDS_Core/DataModel/SolverContext.cs +++ b/dotnet/DDS_Core/DataModel/SolverContext.cs @@ -10,18 +10,35 @@ public sealed class SolverContext : IDisposable #region Constructors and destructors public SolverContext() { - Handle = DdsNative.dds_create_solvercontext_default() - ?? throw new InvalidOperationException("Failed to create SolverContext."); + Handle = Validated(DdsNative.dds_create_solvercontext_default()); } public SolverContext(SolverConfig config) { // Unpacked into scalars: the native shim is pointer-only and // POD-only, so SolverConfig never crosses the ABI boundary. - Handle = DdsNative.dds_create_solvercontext( (int) config.TTKind - , config.DefaultMemoryMB - , config.MaximumMemoryMB) - ?? throw new InvalidOperationException("Failed to create SolverContext."); + Handle = Validated(DdsNative.dds_create_solvercontext( (int) config.TTKind + , config.DefaultMemoryMB + , config.MaximumMemoryMB)); + } + + /// + /// Rejects a failed native creation. The shim returns NULL on failure, + /// but a P/Invoke returning a SafeHandle-derived type never yields null: + /// the marshaller constructs an instance and stores whatever pointer came + /// back, so failure surfaces as IsInvalid, not as a null reference. A + /// `?? throw` here would never fire, leaving every later call to pass + /// IntPtr.Zero into the shim and quietly collect RETURN_UNKNOWN_FAULT. + /// + private static SolverContextHandle Validated(SolverContextHandle handle) + { + if (handle is null || handle.IsInvalid) + { + handle?.Dispose(); + throw new InvalidOperationException("Failed to create SolverContext."); + } + + return handle; } public void Dispose() From 1536c5a315fda73812df7b8b15832cd836a047eb Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Mon, 20 Jul 2026 22:36:18 +0100 Subject: [PATCH 18/21] Fix out-of-bounds aggr_target in the TransTableS reset test The lookup added to ResetAfterReturnAllMemoryIsInert used CreateTestAggrTarget, which fills aggr_target with {0x1111, 0x2222, 0x3333, 0x4444}. lookup() indexes aggp_[aggr_target[ss]], and aggp_ has 8192 (2^13) slots because aggr_target is a 13-bit rank mask -- 0x2222 = 8738 reads past the end. With an all-zero hand_dist the lookup key matches the rebuilt tree root, so it reached that indexing and crashed under a page layout where the overrun hits unmapped memory (it read adjacent heap and passed in fastbuild). Use an in-range all-zero aggr_target. Confirmed under ASan that the test is clean with the reset guard and still SEGVs without it, so it remains a genuine guard for the fix. Co-Authored-By: Claude Opus 4.8 --- library/tests/trans_table/trans_table_s_test.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/library/tests/trans_table/trans_table_s_test.cpp b/library/tests/trans_table/trans_table_s_test.cpp index a0239304..10f7d94e 100644 --- a/library/tests/trans_table/trans_table_s_test.cpp +++ b/library/tests/trans_table/trans_table_s_test.cpp @@ -58,8 +58,11 @@ TEST(TransTableSMemoryTest, ResetAfterReturnAllMemoryIsInert) { tt.init(handLookup); tt.reset_memory(ResetReason::NewDeal); - unsigned short aggrTarget[DDS_SUITS]; - CreateTestAggrTarget(aggrTarget); + // aggr_target entries index aggp_ (8192 = 2^13 slots), so each must be a + // 13-bit rank mask; a wider value reads past the array. An all-zero + // hand_dist matches the tree root init_tt() rebuilt, so this reaches the + // aggp_ indexing and the pos_search_point_ null check on the rebuilt table. + unsigned short aggrTarget[DDS_SUITS] = {0, 0, 0, 0}; int hand_dist[4] = {0, 0, 0, 0}; bool lowerFlag = false; From 2373816523ee107495bbe29896d5044d7a6bad15 Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Tue, 21 Jul 2026 11:15:03 +0100 Subject: [PATCH 19/21] Run the .NET tests in Release as well as Debug in CI ThrowIfError is deliberately not [Conditional("DEBUG")], so a regression to the conditional form only changes behaviour in Release. CI built DDS_Core in Debug only, where DEBUG is defined and the call is emitted regardless -- so SolveBoard_WithInvalidSolutions_Throws, whose whole purpose is to guard that Release still throws, would still pass against a reverted [Conditional("DEBUG")]. The guarantee was untested. Add a second `dotnet test -c Release` run on all three platforms so the regression actually fails CI, and make the test's "every build configuration" claim literally true. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci_linux.yml | 3 +++ .github/workflows/ci_macos.yml | 3 +++ .github/workflows/ci_windows.yml | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/.github/workflows/ci_linux.yml b/.github/workflows/ci_linux.yml index 1d4975f3..c3fd0ae7 100644 --- a/.github/workflows/ci_linux.yml +++ b/.github/workflows/ci_linux.yml @@ -64,7 +64,10 @@ jobs: bazel build --verbose_failures //jni:dds_shared export DDS_LIBRARY_PATH="$(bazel info bazel-bin)/jni/libdds.so" test -f "$DDS_LIBRARY_PATH" || { echo "native library not found at $DDS_LIBRARY_PATH"; exit 1; } + # Both configurations: ThrowIfError is unconditional, so a regression to + # [Conditional("DEBUG")] only surfaces in Release. See dotnet-binding.md. dotnet test dotnet/DDS_Core.Tests/ --verbosity normal + dotnet test dotnet/DDS_Core.Tests/ -c Release --verbosity normal # 8️⃣ Upload test logs - name: Upload test logs - Linux diff --git a/.github/workflows/ci_macos.yml b/.github/workflows/ci_macos.yml index b42c2c3e..4695eed2 100644 --- a/.github/workflows/ci_macos.yml +++ b/.github/workflows/ci_macos.yml @@ -64,7 +64,10 @@ jobs: bazelisk build --verbose_failures //jni:dds_shared export DDS_LIBRARY_PATH="$(bazelisk info bazel-bin)/jni/libdds.dylib" test -f "$DDS_LIBRARY_PATH" || { echo "native library not found at $DDS_LIBRARY_PATH"; exit 1; } + # Both configurations: ThrowIfError is unconditional, so a regression to + # [Conditional("DEBUG")] only surfaces in Release. See dotnet-binding.md. dotnet test dotnet/DDS_Core.Tests/ --verbosity normal + dotnet test dotnet/DDS_Core.Tests/ -c Release --verbosity normal # Upload test logs - name: Upload test logs - macOS diff --git a/.github/workflows/ci_windows.yml b/.github/workflows/ci_windows.yml index 7d50cf20..ece78c2d 100644 --- a/.github/workflows/ci_windows.yml +++ b/.github/workflows/ci_windows.yml @@ -80,8 +80,12 @@ jobs: Write-Host "native library not found at $env:DDS_LIBRARY_PATH" exit 1 } + # Both configurations: ThrowIfError is unconditional, so a regression to + # [Conditional("DEBUG")] only surfaces in Release. See dotnet-binding.md. dotnet test dotnet/DDS_Core.Tests/ --verbosity normal if ($LASTEXITCODE -ne 0) { exit 1 } + dotnet test dotnet/DDS_Core.Tests/ -c Release --verbosity normal + if ($LASTEXITCODE -ne 0) { exit 1 } # Upload test logs - name: Upload test logs - Windows From a18255ab1ae9b99e111bb3a77b97cb3d0586334b Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Tue, 21 Jul 2026 11:15:13 +0100 Subject: [PATCH 20/21] Sync clear_tt developer note with its disposal semantics The reset-semantics note still said clear_tt() "returns all TT memory to the system", while the function's @brief body and the .cpp now describe disposing the TT instance. Reword the note to match so the header does not describe two different behaviours for the same call. Co-Authored-By: Claude Opus 4.8 --- library/src/solver_context/solver_context.hpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/src/solver_context/solver_context.hpp b/library/src/solver_context/solver_context.hpp index acd209d2..1cdfd531 100644 --- a/library/src/solver_context/solver_context.hpp +++ b/library/src/solver_context/solver_context.hpp @@ -174,8 +174,8 @@ class SolverContext // tt->reset_memory(FreeMemory) when a TT exists; // preserves the TT allocation for reuse. // reset_best_moves_lite() — clears only best-move ranks and updates memUsed. - // clear_tt() — returns all TT memory to the system; preserves - // future config and recreates lazily on demand. + // clear_tt() — disposes the TT instance; preserves future + // config and recreates lazily on demand. // dispose_trans_table() — destroys the owned TT immediately. // - Diagnostics: When built with DDS_UTILITIES_LOG / DDS_UTILITIES_STATS, TT // lifecycle events append compact log entries and bump small counters. From a40fd748ecda634625a2e583c643f4995df44df8 Mon Sep 17 00:00:00 2001 From: Martin Nygren Date: Tue, 21 Jul 2026 14:12:44 +0100 Subject: [PATCH 21/21] fix: updates the bazel lock file. --- MODULE.bazel.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 17be65cb..b7816577 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -328,7 +328,7 @@ "@@emsdk+//:emscripten_deps.bzl%emscripten_deps": { "general": { "bzlTransitiveDigest": "CpCK8JdAd4sMkjDZLg/mBRxzNO6d3nEkAW2H4mMike4=", - "usagesDigest": "lqS0hMGr6MqmX63BGZOskMFcqzqO53K0UlYFxuE3QSU=", + "usagesDigest": "wvQRffIp8pEJeKXankBKEQBAOCI8HTtmw9TvnW7/e1c=", "recordedInputs": [ "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals", "REPO_MAPPING:bazel_features+,bazel_features_version bazel_features++version_extension+bazel_features_version",