Skip to content

Commit ff79ffb

Browse files
fryanpanclaude
andcommitted
ADFA-4128: qb 09 review fixes — snapshot defense, baseline semantics, d8 + stable-ids surfacing
Review findings (PR #1721, all four Important items): 1. Stale shrunk-snapshot on re-configure -> configure fingerprints the classpath jars (path+size+CRC) and wipes shrunk-classpath-snapshot.bin plus ic/ when the bytes changed, keeping them when identical. Covered by IncrementalCompilerTest "re-configuring over an in-place rewritten classpath jar discards the stale shrunk snapshot" and its byte-identical keep-warm companion. 2. "Deployed" baseline that no deploy ever acks -> deployedOutputs renamed to lastGoodOutputs with honest KDoc, and a compile declaring EVERY source changed now rebaselines: the output diff runs against nothing and reports the whole tree, giving clients a wire-compatible recovery after a failed dex/deploy. Covered by IncrementalCompilerTest "declaring every source changed rebaselines - the whole output tree is reported changed". ROUTED(qb-08 core-orchestration / qb-11 app): the orchestrator must still force a full-changed compile (ChangedFiles.Unknown) after a failed dex/deploy; today it only re-queues the batch. No protocol-module change. 3. d8 diagnostics not captured -> a DiagnosticsHandler proxy is installed via D8Command.builder(handler); collected error diagnostics are appended (bounded) to the Failed message instead of the bare "Compilation failed to complete". Covered by DexToolEdgeTest "a d8 failure surfaces d8's own error diagnostics, not only the generic message" (runtime-compiled fake r8, runs untethered). 4. Silent stable-ids degrade -> relink fails a named-but-missing stableIds file before aapt2 runs; only an explicit null links unpinned. Covered by Aapt2LinkEdgeTest "a named but missing stable-ids file fails the relink instead of silently linking unpinned". Tests are written to fail without their fix but were NOT executed here (no-build constraint on this fix pass); verify with :quickbuild:daemon:test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W
1 parent 2ba0f4a commit ff79ffb

8 files changed

Lines changed: 459 additions & 19 deletions

File tree

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,9 @@ class DaemonService(
179179
* Compiles the requested sources and reports the changed class outputs plus phase timings.
180180
*
181181
* @param request must list every module source in `allSources`, not only the edited ones,
182-
* and repeat them all in `changedFiles` on a session's first compile.
182+
* and repeat them all in `changedFiles` on a session's first compile - and again to
183+
* rebaseline after a failed dex or deploy, which makes `classesChanged` report the whole
184+
* tree instead of a diff against outputs the device never received.
183185
* @return ok with `classesDir`, the phase timings and the `classesChanged` path list, or
184186
* ok:false carrying the compiler diagnostics; ok:false if no `configure` ran first.
185187
*/
@@ -278,6 +280,8 @@ class DaemonService(
278280
*
279281
* @param request `stableIds` and `libraryResources` are optional on the wire but omitting
280282
* either risks a wrong-id crash or an unresolvable reference - see [Aapt2Link]'s KDoc.
283+
* A `stableIds` path that is supplied but missing on disk fails the relink rather than
284+
* silently linking unpinned.
281285
* @return ok with `resourcesArsc` (the full relinked apk) and the aapt2 timings, or ok:false
282286
* carrying the aapt2 diagnostics; ok:false if no `configure` ran first.
283287
*/

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

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ private typealias OutputSnapshot = Map<String, Pair<Long, Long>>
3131
* - The caller must pass ALL sources as changed on the first compile, to seed the IC caches.
3232
* - `assureNoClasspathSnapshotsChanges(true)` is only safe once the shrunk snapshot exists;
3333
* before that the engine needs the full classpath comparison to seed.
34+
* - A shrunk snapshot left in [workDir] by a previous session describes THAT session's
35+
* classpath bytes, so `init` fingerprints the jars and discards the snapshot plus the IC
36+
* caches on a mismatch - otherwise the first compile asserts "classpath unchanged" over a
37+
* classpath a standard Gradle build may have rewritten in place, and stale dependents ship
38+
* silently (see [discardStaleIncrementalState]).
3439
*
3540
* Java sources take two passes: kotlinc reads them for symbol resolution only, then javac
3641
* compiles them after Kotlin into the same output dir, which is what compiles Kotlin<->Java
@@ -66,8 +71,11 @@ class IncrementalCompiler(
6671
* @property warnings kotlinc's and javac's warnings, already parsed into the protocol
6772
* shape; a successful compile can still carry them.
6873
* @property changedClassFiles the .class files this compile emitted, rewrote or deleted,
69-
* relative to [classesDir]; the deploy policy picks restart vs recreate from it, so it
70-
* is diffed against the last DEPLOYED state and includes deletions.
74+
* relative to [classesDir], for the deploy policy to read. Diffed against the LAST
75+
* SUCCESSFUL COMPILE's tree - no deploy ack reaches the daemon, so that tree is only a
76+
* proxy for what the device runs. A compile that declares every source changed is a
77+
* rebaseline and reports the whole tree; a client whose dex or deploy failed recovers
78+
* "changed vs installed" accuracy exactly that way (see [compile]).
7179
* @property kotlinMillis wall time of the Kotlin pass (0 when there are no Kotlin sources).
7280
* @property javaMillis wall time of the javac pass (0 when there are no Java sources).
7381
* @property stats the phases [kotlinMillis]/[javaMillis] do not cover - the two
@@ -138,16 +146,20 @@ class IncrementalCompiler(
138146
private var pendingJavaAbi: Map<File, JavaSourceAbi.FileAbi>? = null
139147

140148
/**
141-
* The output tree as of the last compile the caller could deploy; null before the first one.
142-
* Held across compiles for the same reason [javaAbi] is: a failed compile leaves output nobody
149+
* The output tree of the last SUCCESSFUL compile; null before the first one. Not the last
150+
* DEPLOYED tree: no deploy ack reaches the daemon, so a compile whose dex or deploy fails
151+
* still promotes here, and the client must rebaseline (declare every source changed, or
152+
* re-configure) before the diff means "changed vs installed" again. Held across FAILED
153+
* compiles for the same reason [javaAbi] is: a failed compile leaves output nobody
143154
* deployed, so re-snapshotting at the top of the next compile would adopt those undeployed
144155
* classes as already-live and drop them from [Result.Success.changedClassFiles].
145156
*/
146-
private var deployedOutputs: OutputSnapshot? = null
157+
private var lastGoodOutputs: OutputSnapshot? = null
147158

148159
init {
149160
Files.createDirectories(icCachesDir)
150161
Files.createDirectories(classesDir)
162+
discardStaleIncrementalState(classpathJars)
151163
val snapshotDir = workDir.resolve("cp-snap")
152164
Files.createDirectories(snapshotDir)
153165
// Snapshot the fixed session classpath once; a classpath change is a session
@@ -165,12 +177,66 @@ class IncrementalCompiler(
165177
}
166178
}
167179

180+
/**
181+
* Discards a previous session's shrunk snapshot and IC caches when this session's classpath
182+
* BYTES differ from the ones that produced them.
183+
*
184+
* The shrunk snapshot is keyed by path alone (`<rootProjectDir>/shrunk-classpath-snapshot.bin`),
185+
* so it survives a re-configure into the same [workDir] - and [compileKotlin] then runs
186+
* `assureNoClasspathSnapshotsChanges(true)` over a classpath a standard Gradle build may have
187+
* rewritten in place (same jar paths, new ABI). Any compile trusting that assertion keeps
188+
* dependents of the changed library ABI stale, the worst silent failure this feature has.
189+
* Fingerprinting path+size+CRC of every jar catches the in-place rewrite; a mismatch (or a
190+
* missing fingerprint next to surviving state) wipes both, and the next compile re-seeds from
191+
* the fresh per-jar snapshots. Matching bytes keep the warm caches, which re-configures with
192+
* an unchanged classpath must not lose.
193+
*
194+
* @param classpathJars the session classpath, fingerprinted before the per-jar snapshots are
195+
* computed over it.
196+
*/
197+
private fun discardStaleIncrementalState(classpathJars: List<File>) {
198+
val fingerprintFile = workDir.resolve("classpath-fingerprint.txt").toFile()
199+
val fingerprint =
200+
classpathJars.joinToString("\n") { jar ->
201+
"${jar.absolutePath}|${jar.length()}|${if (jar.isFile) contentCrc(jar) else -1L}"
202+
}
203+
val previous = if (fingerprintFile.isFile) fingerprintFile.readText() else null
204+
if (previous != fingerprint) {
205+
shrunkSnapshot.delete()
206+
icCachesDir.toFile().deleteRecursively()
207+
Files.createDirectories(icCachesDir)
208+
}
209+
fingerprintFile.writeText(fingerprint)
210+
}
211+
212+
/**
213+
* CRC32 of a whole file, streamed - classpath jars run tens of MB, so no
214+
* [checksumOf]-style whole-file read on a 2-4 GB phone.
215+
*
216+
* @param file the jar to checksum; must exist.
217+
* @return the CRC32 of its bytes.
218+
*/
219+
private fun contentCrc(file: File): Long {
220+
val crc = CRC32()
221+
file.inputStream().use { input ->
222+
val buffer = ByteArray(FINGERPRINT_READ_BUFFER_BYTES)
223+
while (true) {
224+
val read = input.read(buffer)
225+
if (read < 0) break
226+
crc.update(buffer, 0, read)
227+
}
228+
}
229+
return crc.value
230+
}
231+
168232
/**
169233
* Runs one compile: the incremental Kotlin pass, then javac over any `.java` sources.
170234
*
171235
* @param allSources every source in the module, not just the edited ones.
172236
* @param changedFiles sources edited since the last compile; pass all of [allSources] on
173-
* the first compile of a session.
237+
* the first compile of a session. Declaring every source changed is also the REBASELINE
238+
* signal: the output diff then runs against nothing, reporting the whole tree as changed,
239+
* which is how a client recovers after a failed dex or deploy (see [lastGoodOutputs]).
174240
* @param removedFiles sources deleted since the last compile, no longer in [allSources];
175241
* their stale `.class` outputs are cleaned before anything is compiled.
176242
* @return [Result.Failed] on any compile error, and also when a removed source's stale
@@ -201,8 +267,14 @@ class IncrementalCompiler(
201267
compileCount++
202268
javaAbiSnapMillis = 0
203269
kotlinToCompileCount = 0
270+
// A compile declaring EVERY source changed is a rebaseline: the session's first compile,
271+
// or a client that stopped trusting what the device runs because a dex or deploy failed
272+
// (no deploy ack reaches the daemon - see lastGoodOutputs). Diffing it against the last
273+
// compile's never-deployed tree would answer "nothing changed" for classes the device
274+
// has never received, so it diffs against nothing and reports the whole tree.
275+
val rebaseline = allSources.isNotEmpty() && changedFiles.toSet().containsAll(allSources)
204276
val preSnapStartedAt = System.currentTimeMillis()
205-
val before = deployedOutputs ?: snapshotClassOutputs()
277+
val before = if (rebaseline) emptyMap() else (lastGoodOutputs ?: snapshotClassOutputs())
206278
val preSnapMillis = System.currentTimeMillis() - preSnapStartedAt
207279
val logger = CollectingLogger(compileLog)
208280
val kotlinStartedAt = System.currentTimeMillis()
@@ -267,8 +339,9 @@ class IncrementalCompiler(
267339
val changedClassFiles = changedClassOutputs(before, after)
268340
val postSnapMillis = System.currentTimeMillis() - postSnapStartedAt
269341
// Same rule as the ABI above: this output only becomes the baseline because the caller
270-
// can now deploy it.
271-
deployedOutputs = after
342+
// can now deploy it - whether the deploy then LANDS is invisible here, which is why a
343+
// client whose deploy failed must rebaseline (see the field).
344+
lastGoodOutputs = after
272345
return Result.Success(
273346
classesDir = classesDir.toFile(),
274347
warnings = warnings + javaDiagnostics.diagnostics,
@@ -332,7 +405,7 @@ class IncrementalCompiler(
332405
/**
333406
* Diffs two output-tree walks into the paths the deploy has to account for.
334407
*
335-
* @param before the last deployed state.
408+
* @param before the last successful compile's state, or empty on a rebaseline.
336409
* @param after this compile's state.
337410
* @return added, rewritten AND deleted paths - a deletion has to be in here, since dropping a
338411
* nested class of a restart-sensitive component is a change the deploy policy must see and
@@ -611,5 +684,8 @@ class IncrementalCompiler(
611684
companion object {
612685
// ART (via d8 desugaring) handles Java-17 bytecode; matches the bundled JDK.
613686
private const val JVM_TARGET = "17"
687+
688+
/** Read-chunk size for [contentCrc]'s streamed jar checksum. */
689+
private const val FINGERPRINT_READ_BUFFER_BYTES = 64 * 1024
614690
}
615691
}

quickbuild/daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/dex/DexTool.kt

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ package org.appdevforall.cotg.quickbuild.daemon.dex
22

33
import org.appdevforall.cotg.quickbuild.protocol.DexStats
44
import java.io.File
5+
import java.lang.reflect.InvocationHandler
56
import java.lang.reflect.InvocationTargetException
7+
import java.lang.reflect.Method
8+
import java.lang.reflect.Proxy
69
import java.net.URLClassLoader
710
import java.nio.file.Files
811
import java.nio.file.Path
@@ -85,9 +88,10 @@ class DexTool(
8588
if (classFiles.isEmpty()) {
8689
return Result.Failed("no .class files found under: ${classesDirs.joinToString()}")
8790
}
91+
val diagnostics = D8DiagnosticsCollector()
8892
return try {
8993
val d8StartedAt = System.currentTimeMillis()
90-
runD8(classFiles, outDir.toPath())
94+
runD8(classFiles, outDir.toPath(), diagnostics)
9195
val d8Millis = System.currentTimeMillis() - d8StartedAt
9296
val dexFiles = dexFilesIn(outDir)
9397
val failure = dexFailureReason(dexFiles, outDir)
@@ -102,29 +106,54 @@ class DexTool(
102106
)
103107
}
104108
} catch (e: InvocationTargetException) {
105-
Result.Failed("d8 failed: ${e.cause?.message ?: e.cause?.javaClass?.name ?: e.message}")
109+
Result.Failed(d8FailureMessage(e, diagnostics.errors))
106110
} catch (e: ReflectiveOperationException) {
107111
Result.Failed("d8 jar is not usable (wrong build-tools layout?): ${e.message}")
108112
}
109113
}
110114

115+
/**
116+
* The caller-facing message for a d8 compilation failure. The exception's own message is
117+
* near-useless (typically just "Compilation failed to complete"); the real reasons -
118+
* duplicate class, unsupported class file version, malformed input - arrive as error
119+
* diagnostics on the [D8DiagnosticsCollector], so they are appended, bounded so one
120+
* pathological run cannot flood the response.
121+
*
122+
* @param e the reflective d8 failure; its cause's message leads.
123+
* @param errors the run's collected error diagnostics, possibly empty.
124+
* @return one message carrying the cause and every collected error, newline-separated.
125+
*/
126+
private fun d8FailureMessage(
127+
e: InvocationTargetException,
128+
errors: List<String>,
129+
): String {
130+
val cause = "d8 failed: ${e.cause?.message ?: e.cause?.javaClass?.name ?: e.message}"
131+
if (errors.isEmpty()) return cause
132+
return cause + "\n" + errors.joinToString("\n").take(MAX_DIAGNOSTIC_CHARS)
133+
}
134+
111135
/**
112136
* Builds and runs a D8 command reflectively against the device's r8 jar.
113137
*
114138
* @param classFiles the already-stripped `.class` copies, passed as d8 program inputs.
115139
* @param outDir d8's output dir, written in `DexIndexed` mode.
140+
* @param diagnostics receives the run's diagnostics; without it d8 prints its real failure
141+
* reasons to the default handler's stderr, which the client only ever logs.
116142
* @throws java.lang.reflect.InvocationTargetException wrapping any d8 compilation error.
117143
* @throws ReflectiveOperationException when the r8 jar does not expose the expected API.
118144
*/
119145
private fun runD8(
120146
classFiles: List<Path>,
121147
outDir: Path,
148+
diagnostics: D8DiagnosticsCollector,
122149
) {
123150
val commandClass = loader.loadClass("com.android.tools.r8.D8Command")
124151
val outputModeClass = loader.loadClass("com.android.tools.r8.OutputMode")
152+
val handlerClass = loader.loadClass("com.android.tools.r8.DiagnosticsHandler")
125153
val dexIndexed = outputModeClass.enumConstants.first { (it as Enum<*>).name == "DexIndexed" }
126154

127-
val builder = commandClass.getMethod("builder").invoke(null)
155+
val handler = Proxy.newProxyInstance(loader, arrayOf(handlerClass), diagnostics)
156+
val builder = commandClass.getMethod("builder", handlerClass).invoke(null, handler)
128157
val builderClass = builder.javaClass
129158
builderClass
130159
.getMethod("addProgramFiles", Collection::class.java)
@@ -194,7 +223,92 @@ class DexTool(
194223
loader.close()
195224
}
196225

226+
/**
227+
* Stands in for r8's `DiagnosticsHandler` behind a [Proxy], collecting the error messages
228+
* of one d8 run - so a failed dex can surface WHY (duplicate class, unsupported class file
229+
* version, malformed input) instead of the exception's generic "Compilation failed to
230+
* complete". Reflective throughout: it may reference no r8 type, since r8 loads through
231+
* [DexTool]'s private class loader.
232+
*
233+
* `internal` so the reflective message extraction and the pass-through arms are
234+
* unit-testable against fake handler/diagnostic interfaces - real d8 needs a host
235+
* toolchain.
236+
*/
237+
internal class D8DiagnosticsCollector : InvocationHandler {
238+
/** Messages of the run's error diagnostics, in report order. */
239+
val errors = mutableListOf<String>()
240+
241+
override fun invoke(
242+
proxy: Any,
243+
method: Method,
244+
args: Array<out Any>?,
245+
): Any? {
246+
val argument = args?.firstOrNull()
247+
return when (method.name) {
248+
"error" -> {
249+
if (argument != null) errors += diagnosticMessage(method, argument)
250+
null
251+
}
252+
253+
// Keep whatever level d8 proposed - returning null here would NPE inside d8.
254+
"modifyDiagnosticsLevel" -> {
255+
argument
256+
}
257+
258+
// Object's methods reach the handler too on a Proxy.
259+
"hashCode" -> {
260+
System.identityHashCode(proxy)
261+
}
262+
263+
"equals" -> {
264+
proxy === argument
265+
}
266+
267+
"toString" -> {
268+
"D8DiagnosticsCollector"
269+
}
270+
271+
// warning/info, and anything the interface grows later: droppable for a failure
272+
// report. Void methods take null; others echo a compatible argument so a future
273+
// pass-through default keeps working.
274+
else -> {
275+
if (method.returnType == Void.TYPE) null else args?.firstOrNull { method.returnType.isInstance(it) }
276+
}
277+
}
278+
}
279+
280+
/**
281+
* Reads `getDiagnosticMessage()` - and, best-effort, the origin - through the PUBLIC
282+
* r8 `Diagnostic` interface, which is the handler method's parameter type. Never through
283+
* the argument's own class: d8's diagnostic implementations are typically
284+
* package-private, and invoking a public method through a non-public class throws
285+
* `IllegalAccessException`.
286+
*
287+
* @param method the intercepted handler method, whose parameter type is the interface.
288+
* @param diagnostic the reported diagnostic object.
289+
* @return the diagnostic's message, origin-prefixed when one is available.
290+
*/
291+
private fun diagnosticMessage(
292+
method: Method,
293+
diagnostic: Any,
294+
): String {
295+
val diagnosticType = method.parameterTypes.firstOrNull() ?: return diagnostic.toString()
296+
val message =
297+
runCatching {
298+
diagnosticType.getMethod("getDiagnosticMessage").invoke(diagnostic) as? String
299+
}.getOrNull() ?: diagnostic.toString()
300+
val origin =
301+
runCatching {
302+
diagnosticType.getMethod("getOrigin").invoke(diagnostic)?.toString()
303+
}.getOrNull()
304+
return if (origin.isNullOrBlank() || origin == "unknown") message else "$origin: $message"
305+
}
306+
}
307+
197308
companion object {
309+
/** Cap on the collected-diagnostics tail of a d8 failure message. */
310+
private const val MAX_DIAGNOSTIC_CHARS = 4000
311+
198312
/** `classes.dex`, `classes2.dex`, ... - d8's DexIndexed output names, and nothing else. */
199313
private val DEX_FILE_NAME = Regex("""classes\d*\.dex""")
200314

0 commit comments

Comments
 (0)