-
-
Notifications
You must be signed in to change notification settings - Fork 58
ADFA-4128 (9/11): quickbuild:daemon — the incremental compile service #1721
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bc0e42c
01820d8
89d9d05
a07de34
419271e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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,135 @@ | ||
| 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() | ||
| try { | ||
| serve( | ||
| input = System.`in`.bufferedReader(StandardCharsets.UTF_8), | ||
| output = protocolOut, | ||
| router = RequestRouter(service), | ||
| ) | ||
| } finally { | ||
| // The session's tools outlive the request loop, so release them here rather than | ||
| // leaving it to process teardown - on the fatal-rethrow exit path too. | ||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NITPICK: the read itself sits outside the backstop whose comment says it covers a pathological line. The try below wraps parse and encode, and its comment explains that as protection against "a pathological line, or a response carrying a compile's whole changed-class list". But Move the read inside the try, or narrow the comment to parse and encode. |
||
| 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") | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NITPICK: this module ships without the README both its siblings have.
:quickbuild:coreand:quickbuild:protocoleach carry a moduleREADME.md(core adds one per domain package), andquickbuild/README.mdholds the shared terms - butquickbuild/daemonhas none, so the exit contract, the session warm-state lifecycle and the two-pass Kotlin/Java compile are documented only in KDoc spread across eleven files. REVIEW.md's docs checklist item and CLAUDE.md's "keep docs in step with code" both point at the module README as the place for that.Add
quickbuild/daemon/README.mdcovering the protocol loop, the session lifecycle and the two-pass compile.