Skip to content

Commit 8fa5a27

Browse files
committed
build: guard jdkhttp keep-rules and harden the shrink-survival harness
Ship consumer keep-rules for sdk-transport-jdkhttp so both reference transports protect their construction surface symmetrically, and guard the new rules for real: the harness now bundles the jdkhttp transport and drives an R8-shrunk round-trip through both transports via a shared helper, rather than only okhttp. Depending on the Java-11 jdkhttp module means sdk-shrink-test now targets JDK 11 — the honest model, since the pipeline already runs on a JDK 11 (R8 is Java-11 bytecode and the shrunk program runs on the same launcher). Detekt is skipped here for the same JDK-25-toolchain reason as the other non-8 modules; ktlint and explicit-API strict mode stay on. Also: - Clarify that the harness is shrink-only (obfuscation disabled): correct the app KDoc and expand app-rules.pro on why renaming is out of scope. - r8Run now ignores the exit value and asserts exit code + sentinel in doLast, so a failing shrunk run surfaces its captured output instead of an empty JavaExec abort. - Document in CLAUDE.md that check now needs a JDK 11 toolchain and the Google Maven repo for the shrink-survival step.
1 parent d69e308 commit 8fa5a27

5 files changed

Lines changed: 151 additions & 49 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,17 @@ root `check` task — see `build.gradle.kts`). Detekt is skipped on the two non-
2121
system toolchain when a module targets a non-8 toolchain; see those build scripts for the upstream issue and
2222
re-enable conditions. It runs everywhere else, including `sdk-transport-okhttp`.
2323

24+
`check` (so a plain `./gradlew build`) also runs the R8 shrink-survival guard in the test-only
25+
`sdk-shrink-test` module. That step **requires a JDK 11 toolchain** (Gradle auto-provisions one if absent)
26+
and network access to **Google's Maven repo** to fetch `com.android.tools:r8`. An offline build, or one
27+
that cannot provision JDK 11, will fail on `:sdk-shrink-test:r8Run`; scope the build (e.g. build specific
28+
modules) to skip it. See that module's `build.gradle.kts` for the pipeline.
29+
2430
## Repository Layout
2531

26-
Nine Gradle modules (see `settings.gradle.kts`). `gradle/libs.versions.toml` is the single source of truth
27-
for dependency and plugin versions. Group `org.dexpace`, version `0.0.1-alpha.1`.
32+
Ten Gradle modules (see `settings.gradle.kts`). `gradle/libs.versions.toml` is the single source of truth
33+
for dependency and plugin versions. Group `org.dexpace`, version `0.0.1-alpha.1`. (The tenth,
34+
`sdk-shrink-test`, is a test-only, unpublished R8 shrink-survival guard — not listed below.)
2835

2936
| Module | Purpose | JVM target |
3037
|---|---|---|

sdk-shrink-test/build.gradle.kts

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* SPDX-License-Identifier: MIT
66
*/
77

8+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
9+
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
810
import java.io.ByteArrayOutputStream
911
import java.io.File
1012
import java.util.zip.ZipFile
@@ -13,24 +15,61 @@ plugins {
1315
// Kotlin only — no kover, no maven-publish, no signing. This module produces no published
1416
// artifact and contributes no coverage; it is a build-time regression guard. The root build
1517
// therefore does NOT add it to the kover aggregate, and `apiValidation.ignoredProjects`
16-
// (root build.gradle.kts) keeps it out of the binary-compatibility snapshot. Java-8 bytecode,
17-
// explicit-API strict mode, ktlint, and detekt are all inherited from the root build and left
18-
// ON — the shrink harness honours the same conventions as the published modules.
18+
// (root build.gradle.kts) keeps it out of the binary-compatibility snapshot. Explicit-API
19+
// strict mode and ktlint are inherited from the root build and left ON; detekt is disabled
20+
// below for the same JDK-25-toolchain reason as the other non-Java-8 modules.
1921
kotlin("jvm")
2022
}
2123

2224
group = "org.dexpace"
2325
version = "0.0.1-alpha.1"
2426

27+
// This module targets JDK 11, not the root's Java-8 default. It depends on sdk-transport-jdkhttp
28+
// (Java-11 bytecode) so it can exercise that transport through R8, and the whole shrink pipeline
29+
// already runs on a JDK 11 (R8 itself is Java-11 bytecode, and the shrunk program runs on the same
30+
// 11 launcher) — so a downstream consumer that uses the jdkhttp transport is, by construction, an
31+
// 11+ consumer. Override all three knobs the way sdk-transport-jdkhttp documents (S2.12 — Toolchain
32+
// discipline): the toolchain, the `java {}` source/target, and every Kotlin compile task's target.
33+
kotlin {
34+
jvmToolchain(11)
35+
}
36+
37+
java {
38+
sourceCompatibility = JavaVersion.VERSION_11
39+
targetCompatibility = JavaVersion.VERSION_11
40+
toolchain {
41+
languageVersion.set(JavaLanguageVersion.of(11))
42+
}
43+
}
44+
45+
tasks.withType<KotlinCompile>().configureEach {
46+
compilerOptions {
47+
jvmTarget.set(JvmTarget.JVM_11)
48+
}
49+
}
50+
51+
// Detekt is disabled here for the same reason as sdk-transport-jdkhttp and sdk-async-virtualthreads:
52+
// detekt 1.23.x crashes parsing the JDK 25+ system toolchain version when a module targets a non-8
53+
// toolchain (detekt/detekt#8714). Re-enable when detekt 1.23.x embeds Kotlin >= 2.1.20 or the build
54+
// moves to detekt 2.x. ktlint and explicit-API strict mode stay on.
55+
tasks.matching { it.name == "detekt" }.configureEach {
56+
enabled = false
57+
}
58+
2559
// The shrink harness exercises the SDK exactly as a downstream consumer would: it depends on the
26-
// published modules (core, the Okio I/O adapter, the OkHttp transport, the Jackson serde) and
60+
// published modules (core, the Okio I/O adapter, both reference transports, the Jackson serde) and
2761
// their real transitive runtime dependencies, then bundles the lot into a single program for R8
28-
// to shrink. MockWebServer drives a genuine in-process HTTP round-trip so the shrunk program
29-
// proves the transport still works end-to-end, not merely that its classes survived.
62+
// to shrink. MockWebServer drives a genuine in-process HTTP round-trip through each transport so
63+
// the shrunk program proves they still work end-to-end, not merely that their classes survived.
64+
//
65+
// Both transports are included so the harness guards each one's shipped keep-rules symmetrically:
66+
// dropping a rule from either sdk-transport-okhttp or sdk-transport-jdkhttp would fail this module.
67+
// Depending on the Java-11 jdkhttp module is why this module itself targets JDK 11 (see above).
3068
dependencies {
3169
implementation(project(":sdk-core"))
3270
implementation(project(":sdk-io-okio3"))
3371
implementation(project(":sdk-transport-okhttp"))
72+
implementation(project(":sdk-transport-jdkhttp"))
3473
implementation(project(":sdk-serde-jackson"))
3574
implementation(libs.okhttp.mockwebserver.junit5)
3675

@@ -47,7 +86,7 @@ dependencies {
4786
// ---------------------------------------------------------------------------------------------
4887
// R8 shrink-survival pipeline
4988
//
50-
// buildShrinkInputJar -> fat jar: consumer program + SDK + okio + okhttp + jackson + stdlib
89+
// buildShrinkInputJar -> fat jar: consumer program + SDK + okio + transports + jackson + stdlib
5190
// r8Shrink -> runs R8 in full mode over that jar with the SHIPPED consumer rules
5291
// r8Run -> runs the shrunk program and asserts it prints the success sentinel
5392
//
@@ -83,6 +122,7 @@ val shippedConsumerRulePaths: List<String> =
83122
"META-INF/proguard/sdk-core.pro",
84123
"META-INF/proguard/sdk-io-okio3.pro",
85124
"META-INF/proguard/sdk-transport-okhttp.pro",
125+
"META-INF/proguard/sdk-transport-jdkhttp.pro",
86126
"META-INF/proguard/sdk-serde-jackson.pro",
87127
)
88128

@@ -201,13 +241,25 @@ val r8Run by tasks.registering(JavaExec::class) {
201241

202242
val captured = ByteArrayOutputStream()
203243
standardOutput = captured
244+
// Capture rather than inherit stderr too, so a stack trace from a failing shrunk run is folded
245+
// into the diagnostic below instead of racing the captured stdout to the console.
246+
errorOutput = captured
247+
// Do not let a non-zero exit abort the task before doLast: if the shrunk program throws (the
248+
// SDK did not survive shrinking), JavaExec's default behaviour would fail here and discard the
249+
// captured output — exactly the diagnostic we need. We assert success ourselves in doLast.
250+
isIgnoreExitValue = true
251+
// `executionResult` is a member of JavaExec, but inside `doLast` the lambda receiver is the bare
252+
// Task; capture the provider here so the exit code is reachable when the action runs.
253+
val execResult = executionResult
204254

205255
doLast {
206256
val output = captured.toString("UTF-8")
207257
logger.lifecycle(output.trim())
208-
check(output.contains(successSentinel)) {
209-
"The R8-shrunk consumer did not print the success sentinel. The SDK did not survive " +
210-
"shrinking with its shipped keep-rules. Program output was:\n$output"
258+
val exitValue = execResult.get().exitValue
259+
check(exitValue == 0 && output.contains(successSentinel)) {
260+
"The R8-shrunk consumer did not survive shrinking with its shipped keep-rules " +
261+
"(exit code $exitValue). The SDK surface a downstream R8 build relies on was " +
262+
"stripped or broken. Program output was:\n$output"
211263
}
212264
resultMarker.get().asFile.writeText("ok\n")
213265
}

sdk-shrink-test/src/main/kotlin/org/dexpace/sdk/shrinktest/ShrinkSurvivalApp.kt

Lines changed: 58 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package org.dexpace.sdk.shrinktest
99

1010
import mockwebserver3.MockResponse
1111
import mockwebserver3.MockWebServer
12+
import org.dexpace.sdk.core.client.HttpClient
1213
import org.dexpace.sdk.core.http.common.CommonMediaTypes
1314
import org.dexpace.sdk.core.http.request.Method
1415
import org.dexpace.sdk.core.http.request.Request
@@ -18,6 +19,7 @@ import org.dexpace.sdk.core.io.Io
1819
import org.dexpace.sdk.core.serde.Tristate
1920
import org.dexpace.sdk.io.OkioIoProvider
2021
import org.dexpace.sdk.serde.jackson.JacksonSerde
22+
import org.dexpace.sdk.transport.jdkhttp.JdkHttpTransport
2123
import org.dexpace.sdk.transport.okhttp.OkHttpTransport
2224

2325
/**
@@ -27,8 +29,12 @@ import org.dexpace.sdk.transport.okhttp.OkHttpTransport
2729
* [SUCCESS_SENTINEL] on a clean run.
2830
*
2931
* The harness runs this from the R8-shrunk jar. Because that run performs a real HTTP round-trip
30-
* and a real JSON round-trip, it proves not merely that the kept classes still exist but that their
31-
* members remain wired correctly after tree-shaking and (potential) renaming.
32+
* and a real JSON round-trip, it proves not merely that the kept classes still exist after R8's
33+
* tree-shaking but that their members remain wired correctly and functional. The harness runs R8
34+
* in shrink-only mode (obfuscation is disabled in `src/r8/app-rules.pro`, see the rationale there),
35+
* so it guards against dead-code elimination of the SDK's reflective and SPI surface — not against
36+
* member renaming, which a downstream that also obfuscates would additionally need its own rules
37+
* for the third-party libraries on its classpath.
3238
*/
3339
public object ShrinkSurvivalApp {
3440
/** Printed verbatim to stdout once every exercise below has passed. */
@@ -39,7 +45,7 @@ public object ShrinkSurvivalApp {
3945
// 1. I/O provider seam — install the only adapter through the documented entry point.
4046
Io.installProvider(OkioIoProvider)
4147

42-
exerciseTransportRoundTrip()
48+
exerciseTransportRoundTrips()
4349
exerciseSerdeRoundTrip()
4450

4551
// Reaching here means every kept surface resolved and behaved. Emit the sentinel the
@@ -48,49 +54,64 @@ public object ShrinkSurvivalApp {
4854
}
4955

5056
/**
51-
* Drives a full request/response exchange through [OkHttpTransport] against an in-process
52-
* [MockWebServer]. Exercises the request builder, [RequestBody], the transport's sync
53-
* `execute` path, and reading the [org.dexpace.sdk.core.http.response.Response] body via the
54-
* I/O seam.
57+
* Drives a full request/response exchange through each reference transport — [OkHttpTransport]
58+
* and [JdkHttpTransport] — against an in-process [MockWebServer]. Exercises the request builder,
59+
* [RequestBody], the transport's sync `execute` path, and reading the
60+
* [org.dexpace.sdk.core.http.response.Response] body via the I/O seam. Both transports are
61+
* driven so the harness guards each module's shipped keep-rules; the construction path of either
62+
* being stripped would surface here.
5563
*/
56-
private fun exerciseTransportRoundTrip() {
64+
private fun exerciseTransportRoundTrips() {
5765
MockWebServer().use { server ->
58-
server.enqueue(
59-
MockResponse.Builder()
60-
.code(Status.OK.code)
61-
.addHeader("Content-Type", "application/json")
62-
.body("""{"echo":"pong"}""")
63-
.build(),
64-
)
66+
// One queued response per transport — they each issue a single request below.
67+
repeat(2) {
68+
server.enqueue(
69+
MockResponse.Builder()
70+
.code(Status.OK.code)
71+
.addHeader("Content-Type", "application/json")
72+
.body("""{"echo":"pong"}""")
73+
.build(),
74+
)
75+
}
6576
server.start()
6677

6778
val baseUrl = server.url("/echo").toString()
6879

69-
OkHttpTransport.builder().build().use { transport ->
70-
val request =
71-
Request.builder()
72-
.method(Method.POST)
73-
.url(baseUrl)
74-
.addHeader("Accept", "application/json")
75-
.body(
76-
RequestBody.create(
77-
"""{"ping":"ping"}""",
78-
CommonMediaTypes.APPLICATION_JSON,
79-
),
80-
)
81-
.build()
82-
83-
val response = transport.execute(request)
84-
val status = response.status.code
85-
val payload = response.body?.source()?.readUtf8().orEmpty()
86-
response.close()
87-
88-
check(status == Status.OK.code) { "unexpected status from transport: $status" }
89-
check(payload.contains("pong")) { "unexpected body from transport: $payload" }
90-
}
80+
OkHttpTransport.builder().build().use { roundTrip(it, baseUrl) }
81+
JdkHttpTransport.builder().build().use { roundTrip(it, baseUrl) }
9182
}
9283
}
9384

85+
/**
86+
* Issues one POST through [transport] (typed as the core [HttpClient] SPI, so the exercise stays
87+
* transport-agnostic) and asserts the response came back intact through the I/O seam.
88+
*/
89+
private fun roundTrip(
90+
transport: HttpClient,
91+
baseUrl: String,
92+
) {
93+
val request =
94+
Request.builder()
95+
.method(Method.POST)
96+
.url(baseUrl)
97+
.addHeader("Accept", "application/json")
98+
.body(
99+
RequestBody.create(
100+
"""{"ping":"ping"}""",
101+
CommonMediaTypes.APPLICATION_JSON,
102+
),
103+
)
104+
.build()
105+
106+
val response = transport.execute(request)
107+
val status = response.status.code
108+
val payload = response.body?.source()?.readUtf8().orEmpty()
109+
response.close()
110+
111+
check(status == Status.OK.code) { "unexpected status from transport: $status" }
112+
check(payload.contains("pong")) { "unexpected body from transport: $payload" }
113+
}
114+
94115
/**
95116
* Round-trips a model carrying a [Tristate] field through [JacksonSerde]. This is the most
96117
* shrink-fragile path: Jackson binds the model reflectively and the custom Tristate module

sdk-shrink-test/src/r8/app-rules.pro

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@
1313

1414
# Target a desktop/server JVM, not Android: emit classfiles, do not require a min API, and keep
1515
# enough debug info that a stack trace from the shrunk run is legible.
16+
#
17+
# Obfuscation (member/class renaming) is deliberately OFF. This harness guards SHRINK survival:
18+
# that R8's dead-code elimination does not strip the SDK's reflective and SPI surface. It does not
19+
# guard obfuscation survival, because renaming would also rename the third-party libraries bundled
20+
# here (OkHttp, Okio, Jackson), each of which ships its own consumer keep-rules that a real
21+
# obfuscating consumer applies — reproducing that whole closure is out of scope for this module.
22+
# The SDK's own shipped rules use `-keep ... { *; }`, which already pins names against renaming, so
23+
# enabling obfuscation here would mostly test the bundled libraries' rules, not the SDK's.
1624
-dontobfuscate
1725
-keepattributes SourceFile,LineNumberTable
1826

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Copyright (c) 2026 dexpace and Omar Aljarrah
2+
#
3+
# Licensed under the MIT License. See LICENSE in the project root.
4+
# SPDX-License-Identifier: MIT
5+
6+
# Consumer ProGuard/R8 keep rules for sdk-transport-jdkhttp.
7+
#
8+
# JdkHttpTransport is the public entry point; callers construct it through builder() / create()
9+
# and then use it only through the HttpClient / AsyncHttpClient interfaces. Keep the class, its
10+
# Builder, and the static factories so the construction path survives shrinking. This mirrors the
11+
# rules sdk-transport-okhttp ships for its own transport — every reference transport protects the
12+
# same construction surface.
13+
-keep class org.dexpace.sdk.transport.jdkhttp.JdkHttpTransport { *; }
14+
-keep class org.dexpace.sdk.transport.jdkhttp.JdkHttpTransport$Builder { *; }

0 commit comments

Comments
 (0)