Skip to content

Commit 419271e

Browse files
fryanpanclaude
andcommitted
ADFA-4128: 0902 review round on quickbuild:daemon
Akash's 2 September round, led by the argfile break he verified against build-tools 37.0.0. - A link whose resource paths contain whitespace keeps the inline -R pairs whatever the input count. The argfile format splits on whitespace and has no escape for it, so one space truncated that input and every later one - and the project directory reaches these paths unsanitised, with "My Application" the default new-project name. Pinned by a test that fails without the guard. #1721 (comment) - The inline path deletes a link-inputs.txt an earlier link left behind, which nothing else swept. #1721 (comment) - The unswept-output warning reaches the daemon log. It went to compileLog, which defaults to a no-op and which DaemonService cannot pass without also taking kotlinc's verbose channel, so the warning has its own parameter. #1721 (comment) - javac's options are assembled by an internal function and --release is asserted on the argv. The host JDK emits the same class file version either way, so no compile-and-read test can fail when the flag is dropped. #1721 (comment) - CollectingLogger's KDoc no longer says the result is built from warnings as well as errors; -nowarn means no real compile drives that channel. #1721 (comment) Not fixed here: the FinalStripper/ClassOpener parity test and the DaemonService reconfigure-mutates-the-live-session deferral. Both are ticket-only follow-ups; the ticket text is drafted with this round's replies. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A
1 parent a07de34 commit 419271e

6 files changed

Lines changed: 120 additions & 26 deletions

File tree

quickbuild/daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/DaemonService.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ class DaemonService(
105105
(request.classpath + androidJarPath).map(::File),
106106
outDir.toPath(),
107107
compilerPluginJars = request.compilerPlugins.map(::File),
108+
// Only the compiler's own warnings about the build (an output stem it
109+
// cannot derive, so a stale class goes unswept). compileLog is left
110+
// alone: it carries kotlinc's verbose channel, which would bury the
111+
// daemon log.
112+
warn = log,
108113
),
109114
dexTool = DexTool(File(d8JarPath), File(androidJarPath), request.minApi),
110115
aapt2Link = Aapt2Link(File(aapt2Path), File(androidJarPath)),

quickbuild/daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/compile/IncrementalCompiler.kt

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,16 @@ private typealias OutputSnapshot = Map<String, Pair<Long, Long>>
5454
* @param compileLog takes each level-tagged compiler log line as it is produced and retains
5555
* nothing, since a session-lifetime copy of the engine's verbose debug channel is real memory
5656
* on a 2-4 GB phone.
57+
* @param warn takes this class's own warnings about the build - not the engine's - so they can
58+
* reach the daemon log without the verbose channel coming with them.
5759
*/
5860
@OptIn(ExperimentalBuildToolsApi::class)
5961
class IncrementalCompiler(
6062
classpathJars: List<File>,
6163
private val workDir: Path,
6264
compilerPluginJars: List<File> = emptyList(),
6365
private val compileLog: (String) -> Unit = {},
66+
private val warn: (String) -> Unit = {},
6467
) : AutoCloseable {
6568
/** Outcome of one compile. */
6669
sealed interface Result {
@@ -466,7 +469,7 @@ class IncrementalCompiler(
466469
// outside a java/ or kotlin/ root, as with extraSourceRoots, has no
467470
// derivable stem. Logged anyway, because a silently unswept output is the
468471
// stale-class bug this sweep exists to prevent.
469-
compileLog(
472+
warn(
470473
"w: cannot derive a class output stem for ${javaFile.path}; its stale outputs are not swept",
471474
)
472475
return@forEach
@@ -665,8 +668,9 @@ class IncrementalCompiler(
665668
* `internal` rather than private so severity routing is unit-testable - the daemon passes
666669
* `-nowarn`, so no real compile can drive the warn channel from a test.
667670
*
668-
* Errors and warnings are kept because the compile's result is built from them, and they
669-
* die with the compile. Every line is only forwarded, never accumulated.
671+
* Errors are kept because the compile's result is built from them, and they die with the
672+
* compile. Warnings are collected the same way but cannot reach the result: the daemon
673+
* passes `-nowarn`. Every line is only forwarded, never accumulated.
670674
*
671675
* @property emit takes each line already tagged with its level.
672676
*/

quickbuild/daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/compile/JavaCompileStep.kt

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -55,30 +55,45 @@ object JavaCompileStep {
5555
val fileManager = compiler.getStandardFileManager(collector, Locale.ROOT, StandardCharsets.UTF_8)
5656
fileManager.use { manager ->
5757
val units = manager.getJavaFileObjectsFromFiles(javaSources)
58-
val options =
59-
listOf(
60-
"-classpath",
61-
classpath.joinToString(File.pathSeparator) { it.absolutePath },
62-
"-d",
63-
outputDir.absolutePath,
64-
// Annotation processing is a full-Gradle-build concern;
65-
// running processors here would silently diverge from the real build.
66-
"-proc:none",
67-
"-encoding",
68-
"UTF-8",
69-
// Pin bytecode AND platform APIs to the same level kotlinc targets
70-
// (-jvm-target). Without this a daemon running on JDK 21 emits major-65
71-
// classes next to Kotlin's major-61 in one tree, and java.* resolves
72-
// against the running JDK's own modules instead of release-17 signatures.
73-
"--release",
74-
IncrementalCompiler.JVM_TARGET,
75-
)
58+
val options = javacOptions(classpath, outputDir)
7659
val task = compiler.getTask(StringWriter(), manager, collector, options, null, units)
7760
val success = task.call()
7861
return Result(success, collector.diagnostics.map { it.toProtocol() })
7962
}
8063
}
8164

65+
/**
66+
* The javac options for one compile.
67+
*
68+
* `internal` so the flags are testable: the host JDK compiles this code to the same class
69+
* file version with or without `--release`, so nothing else can tell whether it was passed.
70+
*
71+
* @param classpath the compile classpath, joined with the platform separator.
72+
* @param outputDir the shared Kotlin/Java output tree.
73+
* @return the option list handed to [javax.tools.JavaCompiler.getTask].
74+
*/
75+
internal fun javacOptions(
76+
classpath: List<File>,
77+
outputDir: File,
78+
): List<String> =
79+
listOf(
80+
"-classpath",
81+
classpath.joinToString(File.pathSeparator) { it.absolutePath },
82+
"-d",
83+
outputDir.absolutePath,
84+
// Annotation processing is a full-Gradle-build concern;
85+
// running processors here would silently diverge from the real build.
86+
"-proc:none",
87+
"-encoding",
88+
"UTF-8",
89+
// Pin bytecode AND platform APIs to the same level kotlinc targets
90+
// (-jvm-target). Without this a daemon running on JDK 21 emits major-65
91+
// classes next to Kotlin's major-61 in one tree, and java.* resolves
92+
// against the running JDK's own modules instead of release-17 signatures.
93+
"--release",
94+
IncrementalCompiler.JVM_TARGET,
95+
)
96+
8297
private fun javax.tools.Diagnostic<out JavaFileObject>.toProtocol(): Diagnostic =
8398
Diagnostic(
8499
severity =

quickbuild/daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/res/Aapt2Link.kt

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ class Aapt2Link(
7171
* boundary is testable.
7272
*/
7373
internal const val ARGFILE_THRESHOLD = 100
74+
75+
/** Name of the `@argfile` written beside the link output. */
76+
internal const val ARGFILE_NAME = "link-inputs.txt"
7477
}
7578

7679
/** Outcome of one relink. */
@@ -241,9 +244,12 @@ class Aapt2Link(
241244
* @return the full argv, aapt2's own path included as element 0. Past [ARGFILE_THRESHOLD]
242245
* resource inputs, the whole input list moves into an `@argfile` next to [linkedApk],
243246
* passed as a single `-R @file`. aapt2 expands the file into its whitespace-split paths
244-
* (flags cannot ride along - it rejects them as "missing required flag -o"), every entry
245-
* keeps `-R` overlay semantics in file order, and the space-free scratch paths never trip
246-
* the splitting. Both halves of that expansion are pinned by the argfile relink test.
247+
* (flags cannot ride along - it rejects them as "missing required flag -o") and every
248+
* entry keeps `-R` overlay semantics in file order. Whitespace has no escape in that
249+
* format, so an input path containing any keeps the inline `-R` pairs whatever the
250+
* count: the project directory reaches these paths unsanitised, and the default new
251+
* project is called "My Application". Both halves of the expansion are pinned by the
252+
* argfile relink test.
247253
* @throws IOException when the argfile cannot be written; [relink] turns that into a
248254
* [Result.Failed].
249255
*/
@@ -270,16 +276,27 @@ class Aapt2Link(
270276
arguments += listOf("--stable-ids", stableIds.absolutePath)
271277
}
272278
val resourceInputs = libraryResources + flatFiles
273-
if (resourceInputs.size <= ARGFILE_THRESHOLD) {
279+
val argfile = File(linkedApk.absoluteFile.parentFile, ARGFILE_NAME)
280+
if (resourceInputs.size <= ARGFILE_THRESHOLD || resourceInputs.any(::hasWhitespace)) {
281+
// A previous link may have left one behind; it is stale the moment the inputs
282+
// change, and nothing else deletes it.
283+
argfile.delete()
274284
resourceInputs.forEach { arguments += listOf("-R", it.absolutePath) }
275285
return arguments
276286
}
277-
val argfile = File(linkedApk.absoluteFile.parentFile, "link-inputs.txt")
278287
argfile.writeText(resourceInputs.joinToString("\n") { it.absolutePath })
279288
arguments += listOf("-R", "@${argfile.absolutePath}")
280289
return arguments
281290
}
282291

292+
/**
293+
* Whether [file]'s absolute path holds whitespace, which the argfile format cannot carry.
294+
*
295+
* @param file one resource input, named by its absolute path in the argv either way.
296+
* @return true when the path must be passed inline as its own `-R` argument.
297+
*/
298+
private fun hasWhitespace(file: File): Boolean = file.absolutePath.any { it.isWhitespace() }
299+
283300
/**
284301
* Checks that [linkedApk] actually contains a resource table before it ships as the
285302
* payload - a missing entry means aapt2 produced malformed output despite exit 0. Entry

quickbuild/daemon/src/test/kotlin/org/appdevforall/cotg/quickbuild/daemon/compile/JavaCompileStepTest.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,17 @@ class JavaCompileStepTest {
7575
assertThat(result.diagnostics.map { it.severity }).doesNotContain(Diagnostic.Severity.ERROR)
7676
assertThat(result.diagnostics.any { it.line == null && it.column == null }).isTrue()
7777
}
78+
79+
@Test
80+
fun `javac is pinned to the same release kotlinc targets`() {
81+
// The host JDK produces the same class file version either way, so a compile-and-read
82+
// test cannot fail if the flag is dropped; the argv is where it is observable.
83+
val options = JavaCompileStep.javacOptions(listOf(File(tempDir, "dep.jar")), outputDir())
84+
85+
val release = options.indexOf("--release")
86+
assertThat(release).isAtLeast(0)
87+
assertThat(options[release + 1]).isEqualTo(IncrementalCompiler.JVM_TARGET)
88+
// Annotation processors belong to the full Gradle build, not this one.
89+
assertThat(options).contains("-proc:none")
90+
}
7891
}

quickbuild/daemon/src/test/kotlin/org/appdevforall/cotg/quickbuild/daemon/res/Aapt2LinkTest.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,46 @@ class Aapt2LinkTest {
391391
assertThat(lines.last()).isEqualTo(projectFlat.absolutePath)
392392
}
393393

394+
@Test
395+
fun `a resource path containing a space keeps every input inline`() {
396+
// The argfile format splits on whitespace and has no escape for it, so one space in
397+
// one path silently truncates that input and every later one. The project directory
398+
// reaches these paths unsanitised and the default new project is "My Application",
399+
// which makes this the common case rather than an odd one.
400+
val link = Aapt2Link(File(tempDir, "aapt2"), File(tempDir, "android.jar"))
401+
val spaced = File(tempDir, "My Application/merged_res")
402+
val libraryResources = (1..Aapt2Link.ARGFILE_THRESHOLD + 5).map { File(spaced, "r$it.arsc.flat") }
403+
404+
val arguments =
405+
link.buildLinkArguments(
406+
linkedApk = File(workDir, "linked-res.apk"),
407+
manifest = manifest,
408+
flatFiles = emptyList(),
409+
stableIds = null,
410+
libraryResources = libraryResources,
411+
)
412+
413+
assertThat(arguments.count { it == "-R" }).isEqualTo(libraryResources.size)
414+
assertThat(arguments.filter { it.startsWith("@") }).isEmpty()
415+
assertThat(arguments).containsAtLeastElementsIn(libraryResources.map { it.absolutePath })
416+
}
417+
418+
@Test
419+
fun `the inline path clears an argfile a previous link left behind`() {
420+
val link = Aapt2Link(File(tempDir, "aapt2"), File(tempDir, "android.jar"))
421+
val stale = File(workDir, Aapt2Link.ARGFILE_NAME).apply { writeText("/stale/r1.arsc.flat") }
422+
423+
link.buildLinkArguments(
424+
linkedApk = File(workDir, "linked-res.apk"),
425+
manifest = manifest,
426+
flatFiles = listOf(File(tempDir, "compiled/values_strings.arsc.flat")),
427+
stableIds = null,
428+
libraryResources = emptyList(),
429+
)
430+
431+
assertThat(stale.exists()).isFalse()
432+
}
433+
394434
@Test
395435
@EnabledIf("org.appdevforall.cotg.quickbuild.daemon.TestSdk#aapt2ToolchainAvailable")
396436
fun `relink links through an @argfile when the flat count crosses the threshold`() {

0 commit comments

Comments
 (0)