Exposes Targetcore's flat extern "C" surface to Java through the Panama Foreign
Function Interface (Java 23+), with hand-written wrappers over the generated layer.
| C++ class | Java wrapper | Purpose |
|---|---|---|
P2Paddr |
P2PAddr |
P2P virtual-network address string |
P2PeerMsg |
P2PMsg |
Addressed message with payload |
P2PeerConWsa |
P2PeerConWsa |
Async TCP/IP connection (WSA/IOCP) |
P2PeerHub |
P2PeerHub |
Message routing hub, authentication, receive sink |
| — | Targetcore |
Process lifecycle (startup / cleanup) |
| — | ArmResult, IdResult |
The two result enums the ABI returns as int |
Status: 6/6, re-measured 2026-09-08 against Targetcore.dll 3.0.0.0
(101 flat C entry points, all 101 covered) on a single JDK 25.0.4.1 whose
bundled msvcp140 is 14.40 — see The two requirements pinch below, which one JDK
now satisfies on its own. Run them with .\run_all.ps1.
Renamed 2026-09-08. The library this binds was
P2Pmsgcore.dlland is nowTargetcore.dll;p2pmsgcore_startup/_cleanupbecametargetcore_startup/_cleanup, and eight trust and link-policy entry points arrived with it. The bindings followed the whole way: the directory, the Java package (com.p2pmsgcore->com.targetcore), the generated class (P2Pmsgcore_c->Targetcore_c), the lifecycle class (P2Pmsgcore->Targetcore), the Maven coordinates, and the-Dproperty names. This is a source break -- everyimport com.p2pmsgcore.*has to change. It was taken deliberately: the old name on the published surface would have outlived anyone's memory of why it was there.
From 2026-08-14 to 2026-08-20 the committed bindings covered 72 of the library's
83 entry points. Nothing failed. mvn compile was green and the smoke test
printed "passed" — while printing Hub created: false three lines above it.
The eleven that were missing were not a random tail:
p2peerhub_require_auth p2peerhub_auth_arm
p2peerhub_is_auth_required p2peerhub_auth_arm_text
p2peerhub_set_identity p2peerhub_auth_allow_list_path
p2peerhub_set_allow_list p2peerhub_set_sink
p2peerhub_reload_allow_list p2peerhub_set_sink_u8
p2peerhub_provision_auth
Nine are the whole authentication block and two are the receive sink. Because
authentication became required by default on 2026-08-18, and a hub that cannot
enforce it does not start, a Java caller holding those bindings could not start a
hub at all — and could not turn the requirement off either, because
require_auth was one of the missing eleven.
AbiCoverage now exists so that this fails loudly instead of quietly. It reads
abi-flat.manifest out of the Targetcore checkout — the file VERSIONING.md §2
names as the enumeration of the covered surface — and refuses to pass if any
promised name has no generated binding. It is deliberately not a copy of that list
kept here: a copy is precisely what drifted last time.
Symptom: the first call into the library dies with EXCEPTION_ACCESS_VIOLATION (0xC0000005) inside msvcp140.dll, on the JVM's own stack, with no diagnostic of
any kind. hs_err_pid*.log blames msvcp140.dll+0x12f58.
Cause: a JDK ships its own msvcp140.dll / VCRUNTIME140.dll in bin\, and
jvm.dll imports them, so they are loaded before any of your code runs. Windows
resolves a DLL's imports against whatever module of that base name is already
loaded — so Targetcore.dll gets the JDK's copy, not the system's, and no PATH,
java.library.path or load order can change that. Targetcore is built with MSVC
14.4x and uses std::mutex, whose constructor became constexpr in toolset
14.40 (VS 2022 17.10); against an older runtime it dereferences null.
Measured on 2026-08-20 — same DLL, same non-MFC host, the pre-loaded runtime the only variable:
| runtime in the process | first call into the library |
|---|---|
| 14.36.32532 (Oracle JDK 22 / 23, OpenJDK 22 bundle) | 0xC0000005 |
| 14.40.33810 (Temurin 21 bundle) | ok |
| 14.44.35211 (system redist) | ok |
| 14.42.34438 (jextract 25's own bundled runtime) | ok |
What to do: run on a JDK 23+ whose bin\msvcp140.dll is 14.40 or newer.
run_all.ps1 checks this before it runs anything and stops with an explanation
rather than letting the JVM crash. If your JDK is older, the supported answers are
to use a different JDK, or to build Targetcore with an older toolset — replacing
files inside a JDK is neither.
The two requirements pinch, and on 2026-08-21 no ordinary JDK on the test
machine satisfied both. Compiling needs 23+ (jextract 25 emits
findOrThrow); running needs a bundled CRT of 14.40+. Every JDK 22/23
installed there bundles 14.36, and the one bundling 14.40 is a JDK 21. The way
out is that they need not be the same JDK: compile with a 23+, run with
anything 23+ whose CRT is new enough. jextract 25's own runtime is both —
JDK 25 with msvcp140 14.42 — and was the run JDK through 2026-09-07:
cd java; mvn -q compile # any JDK 23+
cd ..
.\run_all.ps1 -SkipBuild -Java C:\path\to\jextract-25\runtime\bin\java.exe-SkipBuild is required in that split, and the reason is worth knowing before
you hit it: run_all.ps1 points JAVA_HOME at the run JDK before calling
Maven, and jextract's runtime is a trimmed jlink image with no javac, so
letting it compile fails with nothing but "mvn compile failed".
Since 2026-09-08 that split is no longer needed here. A single JDK 25.0.4.1
bundling msvcp140 14.40 satisfies both requirements, so run_all.ps1 compiles
and runs on the one JVM and -SkipBuild can be dropped. The split above is kept
because it is still the answer on any machine whose only 23+ JDK bundles 14.36.
| Tool | Version | Notes |
|---|---|---|
| Java | 23+ | java.lang.foreign left preview in 22, but the floor here is 23 since 2026-08-21: jextract 25 emits SymbolLookup.findOrThrow(), which is a 23 method. The floor is set by the generator, not by anything these bindings need. Verified on 23.0.2. And see the runtime note above. |
| jextract | 25 | Only needed to regenerate; the output is committed. Pinned rather than a floor: jextract 22 emitted find(...).orElseThrow() and 25 emits findOrThrow(), so the tool version decides the Java floor above. Regenerating with an older one lowers it again, and that is a decision rather than an accident. |
| Maven | 3.9+ | mvn compile |
| MSVC | 2022 | builds the DLL |
| A Targetcore checkout | existing | supplies the C wrapper sources, the header jextract reads, and the ABI manifest AbiCoverage checks against |
Throughout, <Targetcore> means the root of that checkout — the directory holding
Targetcore(2022).vcxproj and Targetcore_c.h.
.\run_all.ps1 # stage DLLs, mvn compile, run all six tests
.\run_all.ps1 -Config Debug
.\run_all.ps1 -Java "C:\path\to\jdk\bin\java.exe"
.\run_all.ps1 -LibDir D:\build\Targetcore\ReleaseIt stages Targetcore.dll and the Msgcore.dll from the same build into
bin\, puts that directory on PATH, and reports each test by exit code
(0 PASS, 1 FAIL, 2 SETUP, 3 INCONCLUSIVE).
Both DLLs must come from the same build. A mismatched pair produces the least
helpful error in this toolchain — Cannot open library: Targetcore.dll, naming
the DLL that was found and saying nothing about the dependency that was not.
The C wrapper lives in Targetcore, not here. Targetcore_c.h,
Targetcore_c.cpp and Targetcore_c_u8.cpp are ordinary sources of that project,
listed in both Targetcore(2022).vcxproj and its CMakeLists.txt, so they are
compiled into Targetcore.dll and libtargetcore.so by an ordinary build.
This tree used to carry its own copy under
native\, and Step 1 used to be "copy these three files into Targetcore". That copy was deleted on 2026-08-14: an ABI definition duplicated across two repositories drifts, and this one already had — the copy here sat several fixes behind the library it described, including the handle registry every entry point now depends on.
In the project's Preprocessor Definitions make sure Targetcore_EXPORTS is
defined (it already is for the DLL target; the static DebugLib/ReleaseLib
configurations define Targetcore_STATIC instead, which expands P2PC_API to
nothing — those cannot be loaded by Panama, which needs a shared library).
cmake --build build-win-cmake --config Release --target targetcore
Confirm the surface really is exported — a DLL that built fine still exports
nothing if Targetcore_EXPORTS was missing:
python <Targetcore>\.github\ci\check_abi_exports.py ^
--library build-win-cmake\Targetcore\Release\Targetcore.dll ^
--manifest <Targetcore>\.github\ci\abi-flat.manifest ^
--dumpbin "<VS>\VC\Tools\MSVC\<ver>\bin\Hostx64\x64\dumpbin.exe"
Prebuilt binaries: https://jdk.java.net/jextract/. Verify with
jextract --version. Only needed if you are regenerating.
Run bare, jextract also emits every declaration reachable through
<stdint.h>/<wchar.h> (~40 noise files: FILE, stat, tm, setjmp buffers).
Filter the includes to this header and set the class name:
cd <Targetcore>
:: 1) dump every include option, then keep only the ones from THIS header
jextract --dump-includes jx_dump.txt Targetcore_c.h
findstr /R "^--include-" jx_dump.txt | findstr "Targetcore_c.h" > jx_filter.args
:: 2) generate, filtered, with the class name the wrappers expect
jextract ^
--output <bindings>\java\src\main\java ^
--target-package com.targetcore.native_ ^
--header-class-name Targetcore_c ^
--library Targetcore ^
@jx_filter.args ^
Targetcore_c.hThe filter should come out at 107 lines: 101 --include-function and 6
--include-typedef. If the function count is not 101, the header and this
document have diverged — check the manifest. It was 83 until 2026-08-21, when
the revocation and sealing defaults added ten entry points (Targetcore
ProductionPlan.md Stage 3 steps 19 and 20), and 93 until 2026-09-08, when the
rename brought targetcore_startup / _cleanup and eight trust and link-policy
entry points.
That writes four files into ...\native_\:
| file | what it is |
|---|---|
Targetcore_c.java |
one MethodHandle + typed static method per C function |
Targetcore_c$shared.java |
the layout constants split out |
P2PeerHubSinkFnU8.java |
upcall-stub factory for the UTF-8 receive-sink typedef |
P2PeerHubSinkFn.java |
the same for the wchar_t sink |
Then run AbiCoverage — it is the check that the regeneration was complete.
No-arg functions: the header declares
p2paddr_create(void)/p2peermsg_create(void)with an explicitvoid. Empty()in C means an unprototyped function, which jextract emits as a variadic invoker class rather than a plain no-arg method — keep thevoid.
JDK 22 shim: jextract 25's output calls
SymbolLookup.findOrThrow, added in JDK 23 — measured rather than read: this project compiles on JDK 23.0.2 with the pom atrelease 23. (An earlier draft of this note said 24. It is 23, and 22 is the only version that needs the shim.) To build on 22, rewrite it after generating:Get-ChildItem ..\java\src\main\java\com\targetcore\native_\*.java | ForEach-Object { (Get-Content $_) -replace 'SYMBOL_LOOKUP\.findOrThrow\((\"[^\"]+\")\)', 'SYMBOL_LOOKUP.find($1).orElseThrow()' | Set-Content $_ }— or leave the pom at
release 23and build with a matching JDK, which is what it does.
cd java
mvn compile
java --enable-native-access=ALL-UNNAMED -cp target\classes com.targetcore.SmokeTest
with the directory holding both DLLs on PATH.
-Djava.library.pathis no longer enough, and this changed under us. jextract 25 emitsSymbolLookup.libraryLookup(System.mapLibraryName("Targetcore"), …), which goes through the OS loader search — executable directory, System32,PATH— and does not consultjava.library.pathat all. A wrong path now producesIllegalArgumentException: Cannot open library: Targetcore.dllfrom a static initialiser.run_all.ps1setsPATHfor you.
And
javaitself must be the run JDK, not whateverPATHhands you.mvncompiles atrelease 23usingJAVA_HOME; a barejavais resolved fromPATH, and on a box with several JDKs installed those are routinely not the same one. The result isUnsupportedClassVersionError: class file version 67.0against a tree that is otherwise green -- a compile that worked handed to a runtime that is too old. Spell it"$env:JAVA_HOME\bin\java.exe", or use.\run_all.ps1, which resolves the JVM once and checks its bundled C++ runtime while it is there.
Targetcore.startup(16); // 16 = max hubs; throws if it fails
try {
// ... create/spawn hubs, connections, post messages ...
} finally {
Targetcore.cleanup();
}startup initialises the shared hub/pump locks and the hub-manager table (the
native StartupP2Pmsg). Skipping it used to crash hard in native code on the
first createHub; it now fails cleanly, and SmokeTestGuard is the test that says
so. P2PAddr and P2PMsg are pure object model and do not need it.
Once per process. cleanup() is terminal — a second startup() after it does
not restore a working environment.
Both were measured here on 2026-08-20, and both are invisible from Java because
p2peerhub_create_hub catches the P2Pevent that explains them — deliberately,
since a C++ exception must not unwind across an extern "C" boundary — and returns
a bare 0.
-
One hub per thread.
CreateP2PmsgHubrefuses to associate a second pump with a thread that already has one ("Single P2PmsgPump per thread context"), and closing and destroying the first hub does not release its thread. Three hubs created from the main thread givetrue, false, false; the same three created one per thread all succeed. -
createHub()ORspawnHub(), never both.SpawnHubopens withASSERT(m_nHubID == 0), so the pair trips a Debug assertion — a modal dialog that stops the process — while Release tolerates it silently. This tree's own smoke test made exactly that call pair from the day it was written, and had only ever been run against Release.spawnHub()alone is the normal path: it gives the hub a thread of its own, which is also what rule 1 wants.
Auth is required by default since 2026-08-18 and a hub that cannot enforce it
does not start — createHub() and spawnHub() both refuse before a pump thread
exists, because the alternative is a hub that starts, refuses every peer, and looks
healthy from outside.
try (P2PeerHub hub = new P2PeerHub("Mesh.Node")) {
var prov = hub.provisionAuth("node.key"); // creates the key + node.key.pub
System.out.println(prov.fingerprint()); // read this aloud to the operator
hub.setAllowList("allow.txt"); // who this hub will accept
ArmResult arm = hub.authArm(); // would it start? and why not?
if (!arm.arms()) throw new IllegalStateException(arm.text());
hub.spawnHub();
}provisionAuth does not create the allow-list, and the hub will not arm until
one exists with at least one peer in it. Who to trust is not a thing a library can
supply, and one that wrote an empty allow-list would be answering that question
with "nobody" — which refuses every peer.
The migration for a trusted segment or an in-process router is one deliberate call:
hub.requireAuth(false); // ArmResult.NOT_REQUIRED - starts, and accepts anyoneArmResult.text() is read back through p2peerhub_auth_arm_text, so a Java
diagnostic and the C++ one for the same state cannot disagree. IdResult has no
such entry point on the ABI, so those fourteen names are transcribed from
P2PIdentityStore.h and nothing checks that they still line up — see the class
javadoc.
Everything else on this ABI is post-only. setSink is how an FFI consumer learns
that a message was delivered to its hub.
hub.setSink((src, dst, msgID, data) -> {
queue.add(new Frame(src, msgID, data)); // copy and return; do not block
return true; // consumed
});
hub.spawnHub();- It runs on the hub's pump thread — a thread the JVM never created, attached transparently by Panama on the way into the upcall.
dstis the hub's full address, read from the hub rather than off the message (hub.address()returns the leaf).datais copied out before the handler sees it; the native bytes are valid only for the duration of the call.- Returning
falsehands the message back to the framework, which for a peer with no compiled message map means an undeliverable bounce per message — and that flood is what wedgescloseHub(). - A handler that throws is caught and reported, and the message counts as consumed. An exception escaping an upcall stub does not unwind into C++; it takes the whole JVM down.
- The arena holding the stub is
Arena.ofShared(), notofConfined(): a confined arena throwsWrongThreadExceptionwhen touched from the pump thread, and that throw happens inside the upcall, where it is fatal rather than catchable.
Register before spawnHub() and clear after closeHub(). Swapping one live sink
for another is not safe against a running pump, so setSink refuses to replace one.
The library is built with UNICODE, so TCHAR is wchar_t — UTF-16LE on Windows,
UTF-32 on Linux. Each string-bearing C function therefore has a *_u8 twin that
takes and returns UTF-8 char*, converting at the boundary, and those are
ABI-identical on both platforms.
wchar_t method |
UTF-8 (portable) method |
|---|---|
new P2PAddr(s) |
P2PAddr.ofUtf8(s) |
addr.name() / isChild / isRable |
nameUtf8() / isChildUtf8 / isRableUtf8 |
new P2PMsg(...) |
P2PMsg.ofUtf8(...) / ofMsgIdUtf8(id) |
msg.source/destination/name |
sourceUtf8/destinationUtf8/nameUtf8 |
msg.responseFactory/redirectFactory |
responseFactoryUtf8/redirectFactoryUtf8 |
P2PeerConWsa.clientFactory/serviceFactory |
clientFactoryUtf8/serviceFactoryUtf8 |
con.address() |
con.addressUtf8() |
new P2PeerHub(a) / createHub / connectionExists / address |
P2PeerHub.ofUtf8(a) / createHubUtf8 / connectionExistsUtf8 / addressUtf8 |
Prefer the _u8 methods for encoding-portable behaviour. SmokeTestU8 exercises
them with multibyte UTF-8 (é / € / astral 🚀) and mirrors the native p2p_u8_smoke
CTest.
Paths — identity, allow-list — are UTF-8 on both platforms and have no wide twin.
Sizes are uint32_t, and used not to be. They were unsigned short until
2026-08-14, which put a silent 64 KB wrap in front of every FFI caller. The
wrappers here clamped payloads to Short.MAX_VALUE, reintroducing that truncation
one layer up in Java where the C side could no longer see it; they now pass the
length the caller passed. The cap is still MAX_P2Psize (32768) and an over-cap
message is refused downstream — which surfaces as a null handle, not a short
payload.
isChild takes the candidate child. addr.isChild(x) asks whether x is a
child of addr, not the other way round. This javadoc had it backwards until
2026-08-20; an inverted hierarchy predicate reads as a permission bug much later.
| Test | What it proves | Exit codes |
|---|---|---|
AbiCoverage |
every entry point in abi-flat.manifest has a generated binding |
0/1/2 |
SmokeTest |
every wrapper class end to end, with assertions | 0/1 |
SmokeTestU8 |
the _u8 surface round-trips multibyte UTF-8 |
0/1 |
SmokeTestGuard |
createHub without startup fails cleanly instead of crashing in ntdll |
0/1 |
SmokeTestAuth |
the arm gate: unprovisioned refuses, requireAuth(false) runs, provisioning from Java arms it, and spawnHub is gated too |
0/1 |
SmokeTestSink |
a delivered message reaches Java, on the pump thread, with its four fields intact, and a throwing handler does not kill the JVM | 0/1/3 |
SmokeTest used to print "Smoke test passed." unconditionally. On 2026-08-20 it
was doing that while printing Hub created: false — the hub had stopped starting
two days earlier and nothing was checking. Every line of it is now an assertion.
The C wrapper is not in this repository — it is part of Targetcore:
<Targetcore>\
├── Targetcore_c.h <- extern "C" wrapper header (jextract reads this)
├── Targetcore_c.cpp
├── Targetcore_c_u8.cpp <- the _u8 entry points
└── .github\ci\abi-flat.manifest <- what AbiCoverage checks against
MSCS_JavaBindings\Targetcore\
├── run_all.ps1 stage + build + run + summarise
├── bin\ staged DLLs (generated; not committed)
├── logs\ per-test output (generated; not committed)
└── java\
├── pom.xml
└── src\main\java\com\targetcore\
├── native_\ GENERATED by jextract - do not hand-edit
├── NativeStrings.java wchar_t / UTF-8 <-> String
├── Targetcore.java startup / cleanup
├── P2PAddr.java P2PMsg.java P2PeerConWsa.java P2PeerHub.java
├── ArmResult.java IdResult.java
└── SmokeTest*.java AbiCoverage.java
P2PeerHub.postConnection(con, pump)— the hub takes ownership ofcon; the wrapper callscon.detach()for you.P2PeerHub.postMessage(msg)/P2PeerConWsa.postMessage(msg)— the framework takes ownership ofmsg; the wrapper callsmsg.detach()for you.- Everything else is RAII: use try-with-resources.
Copyright © 2026 Khrustal & Mann, MELBOURNE, VICTORIA, AUSTRALIA, 3000.
Licensed under the Apache License, Version 2.0. See LICENSE for the
full text and NOTICE for what it does and does not cover — in
particular the jextract output under native_\, which carries no per-file header
because it is replaced wholesale every time the bindings are regenerated.