Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 8 additions & 15 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
plugins {
alias(libs.plugins.kotlin.multiplatform) apply false
// 0.3.4-ONLY WORKAROUND (#194) — DELETE THIS LINE with the pluginManagement bump to 0.3.5.
// Declared here (never applied) purely to put the kplusplus plugin on the ROOT buildscript
// classpath, next to the Kotlin plugin. Its version comes from settings.gradle.kts's
// pluginManagement pin, exactly as in the modules that really apply it.
//
// WHY this is load-bearing, not tidiness: since 0.3.4 the plugin drives krapper over ksrpc,
// so its runtime needs kotlinx-coroutines 1.11 / ktor-io 3.5 in the Gradle daemon. Gradle
// gives each project's buildscript its OWN classloader whose PARENT is the root's, and
// class loading is parent-first. With the Kotlin plugin declared only here, the root
// classloader owns kotlin-compiler-runner's kotlinx-coroutines 1.8.0; a module that also
// applies the kplusplus plugin resolves coroutines 1.11.0 into its CHILD classloader, but
// `kotlinx.coroutines.Job` still loads from the parent at 1.8.0. ktor-io then calls
// `Job.invokeOnCompletion$default`, which 1.9+ moved onto the interface itself
// (-Xjvm-default=all) and 1.8.0 keeps in `Job$DefaultImpls`, so kplusplusSync dies with a
// NoSuchMethodError before krapper is even started. Declaring both plugins in the SAME
// (root) block puts them in ONE classpath, where Gradle's conflict resolution picks
// coroutines 1.11.0 and the parent-first lookup finds the right class.
// classpath next to the Kotlin plugin, so both resolve as ONE classpath. 0.3.4's plugin
// carried its ksrpc/ktor/coroutines runtime as ordinary dependencies, which a multi-project
// consumer resolves into a CHILD classloader while parent-first loading keeps serving the
// ROOT's older kotlinx-coroutines — `NoSuchMethodError: Job.invokeOnCompletion$default`
// before krapper is even started. 0.3.5 SHADES that runtime into the plugin jar (see
// compiler/gradle/build.gradle.kts), which is the actual fix; this line is only what keeps
// THIS repo building while it self-hosts one release behind, on 0.3.4.
id("com.monkopedia.kplusplus.compiler") apply false
alias(libs.plugins.ktlint)
}
Expand Down
124 changes: 107 additions & 17 deletions compiler/gradle/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,42 +24,86 @@ plugins {
// owns the Portal side. `publishToMavenLocal` still yields the same coordinates R1 did.
alias(libs.plugins.vannik.publish)
alias(libs.plugins.gradle.plugin.publish)
// #194: SHADE the plugin's runtime dependencies into the jar (see the `shaded`
// configuration + `shadowJar` block below for the full WHY).
alias(libs.plugins.shadow)
}

group = "com.monkopedia.kplusplus"
version = "0.3.4"

// #194: the plugin's runtime dependencies are SHADED — bundled INTO the plugin jar with
// their packages relocated — instead of being declared for the consumer to resolve.
//
// WHY. Gradle gives every project's buildscript its own classloader whose PARENT is the root
// build's, and class loading is PARENT-FIRST. A multi-project build normally declares the
// Kotlin Gradle Plugin only at the ROOT, so the root classloader owns kotlin-compiler-runner's
// kotlinx-coroutines 1.8.0. A module that applies THIS plugin resolves coroutines 1.11.0
// correctly into its CHILD classloader — and never sees it, because `kotlinx.coroutines.Job`
// keeps loading from the parent at 1.8.0. ktor-io then calls `Job.invokeOnCompletion$default`,
// which coroutines 1.9+ moved onto the interface itself (-Xjvm-default=all) while 1.8.0 keeps
// it in `Job$DefaultImpls`: NoSuchMethodError on the first `kplusplusSync` of every
// multi-project consumer (#194 — reachable since #185 put ksrpc, hence ktor + coroutines, on
// the plugin's runtime classpath). Relocating those packages under `shadedPrefix` makes their
// names unique, so there is nothing for a parent classloader to shadow them with, and the
// plugin runs on the exact versions it was compiled against.
//
// These are `shaded` -> `compileOnly`: compiled against the real coordinates, shipped as
// relocated bytecode, and published as NO dependency at all.
val shaded = configurations.create("shaded") {
isCanBeConsumed = false
isCanBeResolved = true
}

val shadedPrefix = "com.monkopedia.kplusplus.compiler.shaded"

// Every third-party package that lands in the jar. Anything bundled but NOT listed here keeps
// its original name and can still collide, so this must cover the whole `shaded` resolution:
// ksrpc (+ the jnanoid id generator it uses), kotlinx coroutines/serialization/io, ktor-io.
val shadedPackages = listOf(
"com.monkopedia.ksrpc",
"com.aventrix.jnanoid",
"kotlinx.coroutines",
// kotlinx-coroutines' stack-trace-recovery marker package — real classes with real
// methods, so a 1.8.0 copy on a parent classloader is the very hazard this fixes.
"_COROUTINE",
"kotlinx.serialization",
"kotlinx.io",
"io.ktor"
)
configurations.compileOnly { extendsFrom(shaded) }
// The tests exercise the session against the REAL (unrelocated) classes the main source set
// was compiled against, so they need those — and the un-bundled `shadow` set, which is on the
// compile classpath but on no test one — on their runtime classpath.
configurations.testImplementation { extendsFrom(shaded, configurations.shadow.get()) }

dependencies {
implementation(kotlin("stdlib"))
implementation(kotlin("gradle-plugin-api"))
// `shadow` is shadow's bucket for "compile against it, do NOT bundle it, DO declare it for
// the consumer": it is the only configuration whose dependencies reach the published POM
// once the shadow jar is the artifact (everything else is expected to be inside the jar).
// These three are the Kotlin runtime pieces the Gradle daemon / KGP already supply on the
// parent classloader — relocating them is not even meaningful.
shadow(kotlin("stdlib"))
shadow(kotlin("gradle-plugin-api"))
// ksrpc-api reaches for kotlin-reflect at runtime; it used to arrive transitively.
shadow(kotlin("reflect"))
// For KotlinNativeCompilation (MPP-specific). compileOnly because the consumer's
// build always has kotlin-gradle-plugin on the classpath.
compileOnly("org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.0")
// #185: the typed channel to krapper. ksrpc-sockets supplies the JVM
// `ProcessBuilder.asConnection` that speaks the protocol over the subprocess's stdio
// pipe; ksrpc-core + serialization + coroutines are what the generated stubs run on.
//
// Each one EXCLUDES org.jetbrains:annotations: a Gradle buildscript classpath pins that
// module to `strictly 13.0` (Gradle's embedded Kotlin), while coroutines asks for 23.0.0
// — an unsatisfiable constraint that fails the consumer's build at configuration time.
// The excludes are declared per-dependency (not on the configuration) so they are carried
// in the published POM and protect a from-published consumer too. Dropping the module is
// safe: its contents are CLASS-retention annotations, and Gradle's own kotlin-stdlib
// supplies the 13.0 set anyway.
implementation(libs.ksrpc.core) { excludeJetbrainsAnnotations() }
implementation(libs.ksrpc.sockets) { excludeJetbrainsAnnotations() }
implementation(libs.serialization.json) { excludeJetbrainsAnnotations() }
implementation(libs.coroutines.core) { excludeJetbrainsAnnotations() }
shaded(libs.ksrpc.core)
shaded(libs.ksrpc.sockets)
shaded(libs.serialization.json)
shaded(libs.coroutines.core)

// The subprocess-lifecycle tests fault-inject a stand-in `krapper` (see
// KrapperSessionFailureTest); `java-gradle-plugin` already puts gradleApi() on the
// classpath, which is where the Logger they need comes from.
testImplementation(kotlin("test-junit"))
}

fun ExternalModuleDependency.excludeJetbrainsAnnotations() =
exclude(group = "org.jetbrains", module = "annotations")

// #185: ONE definition of the krapper service protocol, compiled for BOTH sides.
//
// The interfaces, the config/filter/fixup payloads and the resolved schema live in
Expand All @@ -79,6 +123,52 @@ java {
targetCompatibility = JavaVersion.VERSION_1_8
}

// #194: the PUBLISHED jar is the shadow jar — this module's own classes plus the `shaded`
// configuration, every bundled package moved under `shadedPrefix` (see the `shaded` block
// above for why). Only third-party runtime packages move; `com.monkopedia.kplusplus.**` (the
// plugin) and `com.monkopedia.krapper.**` (the wire protocol, compiled from shared source)
// keep their names — they are what the plugin IS.
tasks.shadowJar {
// Take the main jar's name: this IS the plugin artifact, not a fat side-car.
archiveClassifier.set("")
// Transformers (below) only see entries the copy spec did not already drop, and the Jar
// default (EXCLUDE) drops same-path duplicates BEFORE they reach one.
duplicatesStrategy = DuplicatesStrategy.INCLUDE
configurations.set(listOf(shaded))
for (pkg in shadedPackages) {
relocate(pkg, "$shadedPrefix.$pkg")
}
// ServiceLoader registrations are resource FILES, so relocating the bytecode alone would
// leave them pointing at (and named after) classes that no longer exist —
// kotlinx-coroutines' CoroutineExceptionHandler / MainDispatcherFactory providers among
// them. mergeServiceFiles() rewrites both the entry names and their contents.
mergeServiceFiles()
// Never BUNDLE what the Gradle runtime already provides on the parent classloader, and
// what relocating would break anyway: the Kotlin runtime (stdlib + reflect), slf4j-api
// (Gradle's own logging API — a relocated copy would find no provider), and
// org.jetbrains:annotations (CLASS-retention only). Filtered here rather than as
// configuration-level excludes because `compileOnly` extends `shaded`, and exclude rules
// are inherited down that chain — they would strip the Kotlin API off our OWN compile
// classpath.
dependencies {
exclude(dependency("org.jetbrains.kotlin:.*:.*"))
exclude(dependency("org.jetbrains:annotations:.*"))
exclude(dependency("org.slf4j:.*:.*"))
}
// Dependency-jar metadata that must not be republished: signatures (invalid once the jar
// is rebuilt) and module-info descriptors (they name the ORIGINAL modules/packages).
exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA")
exclude("**/module-info.class")
}

// The plain `jar` would write the same archive name as the shadow jar above, and there is
// nothing left for it to produce: the shadow plugin already makes its jar the artifact of the
// `java` component, so the shadow jar is what gets published, what the Plugin Portal uploads,
// and what an `includeBuild` consumer substitutes to.
tasks.jar {
enabled = false
}

gradlePlugin {
website.set("https://github.com/Monkopedia/kplusplus")
vcsUrl.set("https://github.com/Monkopedia/kplusplus.git")
Expand Down
11 changes: 11 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ repositories {
}
```

In a **multi-project** build, apply this in whichever project owns the bindings; the plugin
does not need to be declared at the root alongside the Kotlin plugin. (Under **0.3.4 only** it
did — the plugin's own runtime classes were shadowed by whatever the root buildscript
classloader owned, and the first `kplusplusSync` failed with
`NoSuchMethodError: Job.invokeOnCompletion$default`. Adding
`id("com.monkopedia.kplusplus.compiler") apply false` to the root `plugins { }` block works
around it on 0.3.4; upgrading is the real fix. See
[#194](https://github.com/Monkopedia/kplusplus/issues/194).)

## 2. Configure the binding

Add a `kplusplus { }` block pointing at the header(s) and library you want to bind:
Expand Down Expand Up @@ -109,6 +118,8 @@ cryptic `file not found` deep in the parse. When `llvm-config` reports a default
## See also

- [`samples/minimal`](../samples/minimal) — the smallest end-to-end binding, a working copy of this guide.
- [`samples/multiproject`](../samples/multiproject) — the same thing as two Gradle projects, with the
Kotlin plugin declared at the root.
- [`samples/v8`](../samples/v8) — the heavyweight demo (binds and runs V8).
- [README](../README.md) — the `kplusplus { }` DSL reference and `fixup { }` escape hatch.
- [docs/ARCHITECTURE.md](ARCHITECTURE.md) — how the self-hosted front-end and generator fit together.
4 changes: 4 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
# 3. ...and that the generated bindings still match what the sample expects (the
# `samples/minimal:nativeTest` guard for #195 — see samples/minimal/README.md):
(cd samples/minimal && ./gradlew nativeTest)
# 4. ...and that a MULTI-PROJECT consumer can drive the plugin at all (the #194 guard: the
# plugin's runtime is shaded into its jar precisely so a subproject's classloader can't be
# shadowed by the root's — see samples/multiproject/README.md):
(cd samples/multiproject && ./gradlew :bindings:build)
```

`publishToMavenLocal` does **not** sign (no key in the environment) — signing is exercised only
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ klinker = "0.2.0"
gradle-plugin-publish = "2.1.1"
vannik = "0.37.0"
binary-compatibility-validator = "0.18.0"
shadow = "9.6.1"

[libraries]
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
Expand All @@ -32,3 +33,4 @@ klinker = { id = "com.monkopedia.klinker.plugin", version.ref = "klinker" }
gradle-plugin-publish = { id = "com.gradle.plugin-publish", version.ref = "gradle-plugin-publish" }
vannik-publish = { id = "com.vanniktech.maven.publish", version.ref = "vannik" }
binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" }
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
54 changes: 54 additions & 0 deletions samples/multiproject/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# samples/multiproject

The **multi-project canary**. Two Gradle projects: a root that declares the Kotlin Gradle
Plugin, and a `:bindings` subproject that applies kplusplus to one header-only C++ struct.
It is not a showcase — `samples/minimal` is the sample to read first. This one exists to keep
a specific failure mode visible.

## Why it exists

Gradle gives every project's buildscript its own classloader whose **parent** is the root
build's, and class loading is **parent-first**. A multi-project build normally declares the
Kotlin plugin once, at the root — which puts `kotlin-compiler-runner`'s own
`kotlinx-coroutines` on that *parent* classloader. A subproject applying kplusplus resolves
the coroutines version the plugin actually needs into its *child* classloader and never sees
it, because the parent answers first.

That is [#194](https://github.com/Monkopedia/kplusplus/issues/194): kplusplus 0.3.4 drove
krapper over ksrpc (#185), ktor-io called `Job.invokeOnCompletion$default` — a method
coroutines 1.9+ moved onto the interface and 1.8.0 keeps in `Job$DefaultImpls` — and every
multi-project consumer died with `NoSuchMethodError` on its first `kplusplusSync`. The fix
shades the plugin's runtime dependencies into the plugin jar (see
`compiler/gradle/build.gradle.kts`). It shipped broken because **every other sample is
single-project**, where the two plugins land in one `plugins { }` block and one classloader,
and nothing can shadow anything.

So: keep this sample two projects, and keep the Kotlin plugin declared **only at the root**.
That is the entire test. What it binds does not matter.

## Run it

The plugin is resolved by coordinate (`mavenLocal()` first), so publish the build under test
first — the release local dry run, from the repo root:

```sh
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
./gradlew :krapper:linkReleaseExecutableKlinker
./gradlew -p compiler publishToMavenLocal \
-Pkpp.bundleTools.krapper="$PWD/krapper/build/bin/klinker/krapperRelease/krapper"
```

Then, from this directory:

```sh
../../gradlew :bindings:kplusplusSync
```

`kplusplusSync` is the canary: the classloader failure happens when the plugin opens the
service channel to krapper, before any binding is generated. Compiling the generated Kotlin
(`../../gradlew :bindings:build`) exercises the rest, but is not what this sample is for.

`:bindings:build` is step 4 of the release **local dry run** ([docs/releasing.md](../../docs/releasing.md)),
which is where this sample is exercised. It has no CI workflow of its own — unlike
`samples/minimal` it cannot run against the *released* plugin, since the plugin under test is
the point.
49 changes: 49 additions & 0 deletions samples/multiproject/bindings/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright 2026 Jason Monk
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// kplusplus is applied HERE, in a subproject, while the Kotlin plugin comes from the root's
// `plugins { }` block (see ../build.gradle.kts and ../settings.gradle.kts for why that
// arrangement is the whole point of this sample). The version pin matches samples/minimal's:
// this is a from-published consumer.
plugins {
kotlin("multiplatform")
id("com.monkopedia.kplusplus.compiler") version "0.3.4"
}

repositories {
mavenLocal()
mavenCentral()
}

kotlin {
linuxX64("native") {
compilations.all {
compileTaskProvider.configure {
compilerOptions {
optIn.add("kotlinx.cinterop.ExperimentalForeignApi")
// The generated bindings carry `context(scope: MemScope)` on their
// allocating members, which needs Kotlin 2.4 context parameters.
freeCompilerArgs.add("-Xcontext-parameters")
}
}
}
}
}

// One header, no library, no fixups — see cpp/counter.h.
kplusplus {
header("cpp/counter.h")
}
30 changes: 30 additions & 0 deletions samples/multiproject/bindings/cpp/counter.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright 2026 Jason Monk
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once

// The whole C++ surface of the multi-project canary: header-only (every method is defined
// inline, so there is no library to build or link) and std-free, so a sync here exercises the
// PLUGIN, not the binding generator. Deliberately boring — what is under test is whether the
// plugin can talk to krapper at all from a subproject's classloader (#194).
struct Counter {
int value;

Counter() : value(0) {}
explicit Counter(int start) : value(start) {}

int next() { return ++value; }
int peek() const { return value; }
};
Loading