Skip to content
Open
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
168 changes: 168 additions & 0 deletions quickbuild/daemon/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
plugins {
id("java-library")
id("org.jetbrains.kotlin.jvm")
}

description =
"Quick Build warm compile daemon: BTA incremental Kotlin compile + d8 + aapt2, run as a CoGo child process on the bundled JDK (ADFA-4128)"

java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

kotlin {
jvmToolchain(17)
}

// The Compose compiler plugin the daemon passes as -Xplugin when the user project uses
// Compose. Its own configuration (not runtimeClasspath): it is compiler INPUT, not a
// library the daemon's JVM loads. :app's quickBuildDaemonZip stages it next to the
// daemon jar under the stable name compose-compiler-plugin.jar.
val composeCompilerPlugin: Configuration by configurations.creating {
isCanBeConsumed = false
isTransitive = false
}

// Compose runtime for the compose compile tests' classpath. Resolved as the Android
// AAR (what a real project's compile classpath carries); classes.jar is extracted
// below. Test-only - never shipped.
val composeTestRuntimeAar: Configuration by configurations.creating {
isCanBeConsumed = false
isTransitive = false
attributes {
attribute(
Usage.USAGE_ATTRIBUTE,
objects.named(Usage::class.java, Usage.JAVA_RUNTIME),
)
}
}

val stageComposeTestRuntime =
tasks.register<Sync>("stageComposeTestRuntime") {
val aars = composeTestRuntimeAar
from(provider { zipTree(aars.singleFile) }) {
include("classes.jar")
rename("classes.jar", "compose-runtime.jar")
}
into(layout.buildDirectory.dir("compose-test-runtime"))
}

tasks.withType<Test> {
useJUnitPlatform()
// Real inputs, not just dependsOn: a changed plugin or runtime jar must re-run tests.
inputs.files(stageComposeTestRuntime)
inputs.files(composeCompilerPlugin)
systemProperty(
"quickbuild.test.composeRuntimeJar",
layout.buildDirectory
.dir("compose-test-runtime")
.get()
.asFile
.resolve("compose-runtime.jar")
.absolutePath,
)
jvmArgumentProviders.add(
CommandLineArgumentProvider {
listOf("-Dquickbuild.test.composePluginJar=${composeCompilerPlugin.singleFile.absolutePath}")
},
)

// Fail-if-skipped switch for the toolchain-gated tests (aapt2/d8/Compose - the
// ADFA-4128 bug 5/6/8 regression coverage). Opt in with REQUIRE_BUILD_TOOLCHAIN=1
// (env) or -PrequireBuildToolchain: TestSdk then throws from its @EnabledIf
// predicates when the toolchain is absent, failing the tests instead of skipping.
// Also undo the root build's ignoreFailures=true (set for coverage collection) so
// the failure actually fails the build - without that, CI would stay green.
val requireToolchain =
providers.environmentVariable("REQUIRE_BUILD_TOOLCHAIN").orNull == "1" ||
providers.gradleProperty("requireBuildToolchain").isPresent
systemProperty("quickbuild.test.requireToolchain", requireToolchain.toString())
if (requireToolchain) {
ignoreFailures = false
}
}

// DoD coverage gate: >=90% line+branch on non-UI (domain/data) code.
// The root build applies the jacoco plugin to every subproject, which auto-creates
// jacocoTestReport for JVM modules -- but with the XML report off and no dependency
// on the test task, so the gate is never actually measured. The agent's exec lands
// at the JVM default build/jacoco/test.exec (Android modules differ - see
// :quick-build's report and the ADFA-3834 learnings on silently-SKIPped reports).
tasks.named<JacocoReport>("jacocoTestReport") {
dependsOn(tasks.test)
reports {
xml.required.set(true)
html.required.set(true)
}
}

dependencies {
// The wire DTOs/constants, shared with CoGo's client so both sides compile
// against one protocol definition. api: the router/handler signatures expose them.
api(projects.quickbuild.protocol)

implementation(libs.kotlin.buildToolsApi)
implementation(libs.google.gson)
// ACC_FINAL stripping on recompiled payload classes (proxies extend user classes).
implementation(libs.ow2.asm)
// The BTA implementation + its runtime deps are loaded from the daemon's runtime
// classpath on device (staged alongside the jar), matched to the bundled compiler.
// kotlin-compiler-runner exists solely to launch/talk to a separate long-lived
// "Kotlin compile daemon" JVM over RMI, which IncrementalCompiler never does here
// (it always calls useInProcessStrategy()) - dead weight (~17 KB of the ~62 MB
// quickbuild-daemon.zip, ADFA-4128 size audit).
// kotlin-daemon-client and kotlin-daemon-embeddable looked like the same kind of
// dead weight but are NOT: BuildToolsApiBuildICReporter.reportCompileIteration (part
// of kotlin-build-tools-impl itself, on the in-process path) references
// org.jetbrains.kotlin.daemon.common.CompileIterationResult, which lives in
// kotlin-daemon-client - excluding it throws NoClassDefFoundError and failed 12/52
// :quickbuild-daemon:test cases. Keep both.
runtimeOnly(libs.kotlin.buildToolsImpl) {
exclude(group = "org.jetbrains.kotlin", module = "kotlin-compiler-runner")
}

// Staged next to the daemon jar on device and passed as -Xplugin when the user
// project uses Compose.
composeCompilerPlugin(libs.kotlin.composeCompilerPluginEmbeddable)
// The compose compile tests resolve a classpath from this; classes.jar is extracted
// from the AAR at build time and never shipped. Names the -android artifact rather
// than the KMP umbrella, which redirects via available-at - a redirect a
// non-transitive configuration will not follow.
composeTestRuntimeAar(libs.composeRuntimeDaemonTests)

testImplementation(libs.tests.junit.jupiter)
testImplementation(libs.tests.google.truth)
// Shared offline-guard scanner (OfflineNetworkGuardTest).
testImplementation(testFixtures(projects.quickbuild.protocol))
testRuntimeOnly(libs.tests.junit.platformLauncher)
}

/** Single runnable jar; the runtime classpath is staged next to it on device. */
val daemonJar =
tasks.register<Jar>("daemonJar") {
archiveBaseName.set("quickbuild-daemon")
// Not build/libs: the default jar task also writes quickbuild-daemon.jar there,
// and two tasks sharing one archive path trips Gradle's implicit-dependency
// validation in any consumer (:app:quickBuildDaemonZip).
destinationDirectory.set(layout.buildDirectory.dir("daemon-jar"))
manifest {
attributes["Main-Class"] = "org.appdevforall.cotg.quickbuild.daemon.DaemonMain"
attributes["Class-Path"] =
configurations.runtimeClasspath
.get()
.files
.joinToString(" ") { it.name }
}
from(sourceSets.main.get().output)
}

// The manifest Class-Path above names the runtime jars by FILE NAME, resolved
// relative to the jar's own directory. This stages a complete runnable layout
// (jar + deps side by side) so `java -jar build/daemon/quickbuild-daemon.jar`
// works with no manual copy step - what the corpus harness points --daemon-jar at.
tasks.register<Sync>("stageDaemon") {
from(daemonJar)
from(configurations.runtimeClasspath)
into(layout.buildDirectory.dir("daemon"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package org.appdevforall.cotg.quickbuild.daemon

import org.appdevforall.cotg.quickbuild.daemon.protocol.ProtocolCodec
import org.appdevforall.cotg.quickbuild.daemon.protocol.RequestRouter
import org.appdevforall.cotg.quickbuild.protocol.DaemonResponse
import org.appdevforall.cotg.quickbuild.protocol.ParseResult
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.FileDescriptor
import java.io.FileOutputStream
import java.io.OutputStreamWriter
import java.io.PrintStream
import java.io.Writer
import java.nio.charset.StandardCharsets

/**
* Daemon entry point for the line-delimited JSON protocol. main() keeps the real stdout for
* responses and redirects System.out to stderr, since the in-process Kotlin compiler's own prints
* would otherwise corrupt the protocol stream.
*
* Exit contract (quickbuild/README.md): build errors never exit, `shutdown` or stdin EOF exit 0,
* only a fatal internal error exits non-zero. The compiler runs in this JVM, so its own
* [OutOfMemoryError] and [StackOverflowError] are build errors - see [RequestRouter.isRequestFailure].
*/
object DaemonMain {
/**
* Wires the process to the protocol streams and serves until shutdown or EOF.
*
* @param args ignored - the daemon is configured over the protocol, not the command line,
* so a launcher need pass nothing.
*/
@JvmStatic
fun main(args: Array<String>) {
val protocolOut =
BufferedWriter(OutputStreamWriter(FileOutputStream(FileDescriptor.out), StandardCharsets.UTF_8))
System.setOut(PrintStream(FileOutputStream(FileDescriptor.err), true, "UTF-8"))

logErr("started (pid=${ProcessHandle.current().pid()})")
val service = DaemonService()
serve(
input = System.`in`.bufferedReader(StandardCharsets.UTF_8),
output = protocolOut,
router = RequestRouter(service),
)
// The session's tools outlive the request loop, so release them here rather than
// leaving it to process teardown.
service.shutdown()
logErr("exiting")
}

/**
* Runs the request/response loop until shutdown or EOF; malformed input replies ok:false
* and keeps serving. Separated from process wiring so it unit-tests against in-memory
* streams. Single-threaded on purpose - the CoGo orchestrator serializes requests.
*
* @param input one request per line, UTF-8; a null read (EOF) ends the loop, and it is not
* closed here.
* @param output receives one encoded response line per request, flushed after each; must be
* the real stdout, never the redirected [System.out].
* @param router dispatches each parsed request; its [RequestRouter.Routed.ReplyThenExit]
* result is what ends the loop on `shutdown`.
*/
fun serve(
input: BufferedReader,
output: Writer,
router: RequestRouter,
) {
while (true) {
val line = input.readLine() ?: return
if (line.isBlank()) continue

// The router guards the handlers, but parse and encode run outside it, and both
// work on request-sized data: a pathological line, or a response carrying a
// compile's whole changed-class list. An uncaught throw from either would leave the
// loop and exit the JVM, which CoGo reads as daemon death - a restart cycle on every
// save of the same file, with no diagnostic ever rendered.
var routed: RequestRouter.Routed? = null
val encoded =
try {
routed = route(line, router)
ProtocolCodec.encode(routed.response)
} catch (t: Throwable) {
if (!RequestRouter.isRequestFailure(t)) throw t
// Allocation-light on purpose: the OOM arm gets here with the failed work's
// garbage already unreachable, and this response is a few hundred bytes.
// The id is the request's own when only the encode failed, and the codec's
// unknown-id sentinel when the line never parsed.
logErr("request failed: ${t.javaClass.simpleName}")
ProtocolCodec.encode(
DaemonResponse.failure(
routed?.response?.id ?: ParseResult.Malformed.UNKNOWN_ID,
RequestRouter.describe(t),
),
)
}

output.write(encoded)
output.write("\n")
output.flush()

if (routed is RequestRouter.Routed.ReplyThenExit) return
}
}

/**
* Parses one line and routes it, or answers a line the codec rejected.
*
* @param line one request, already known to be non-blank.
* @param router dispatches the parsed request.
* @return what to reply, and whether to keep serving afterwards.
*/
private fun route(
line: String,
router: RequestRouter,
): RequestRouter.Routed =
when (val parsed = ProtocolCodec.parse(line)) {
is ParseResult.Malformed -> {
logErr("malformed request: ${parsed.message}")
RequestRouter.Routed.Reply(
DaemonResponse.failure(parsed.id, "malformed request: ${parsed.message}"),
)
}

is ParseResult.Parsed -> {
router.route(parsed.request)
}
}

private fun logErr(message: String) {
System.err.println("[quickbuild-daemon] $message")
}
}
Loading
Loading