From b4cd32ff54efa2b71dc50aaecaaab285225f5cbf Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 15:03:33 -0700 Subject: [PATCH 01/26] =?UTF-8?q?ADFA-4128:=20qb=2007/12=20core-provisioni?= =?UTF-8?q?ng=20=E2=80=94=20Core=20slice=203:=20proxy-app=20install=20stat?= =?UTF-8?q?e=20and=20the=20compile-daemon=20client=20the=20pipeline=20need?= =?UTF-8?q?s=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W --- .../quickbuild/data/DaemonProcessClient.kt | 559 ++++++++ .../quickbuild/data/FileGenerationStore.kt | 72 + .../cotg/quickbuild/data/ProxyAppInfo.kt | 301 +++++ .../cotg/quickbuild/data/QuickBuildDaemon.kt | 246 ++++ .../cotg/quickbuild/data/QuickBuildPaths.kt | 60 + .../data/QuickBuildProjectLayout.kt | 159 +++ .../cotg/quickbuild/data/QuickBuildScratch.kt | 188 +++ .../service/provision/ProxyAppInstaller.kt | 398 ++++++ .../provision/QuickBuildClobberCheck.kt | 42 + .../provision/QuickBuildProvisioner.kt | 150 +++ .../quickbuild/service/provision/README.md | 11 + .../session/QuickBuildDaemonController.kt | 229 ++++ .../data/DaemonProcessClientEdgeTest.kt | 1172 +++++++++++++++++ .../data/DaemonProcessClientTest.kt | 244 ++++ .../data/FileGenerationStoreEdgeTest.kt | 66 + .../data/FileGenerationStoreTest.kt | 70 + .../quickbuild/data/ProxyAppInfoEdgeTest.kt | 280 ++++ .../cotg/quickbuild/data/ProxyAppInfoTest.kt | 299 +++++ .../data/QuickBuildProjectLayoutTest.kt | 166 +++ .../data/QuickBuildScratchEdgeTest.kt | 73 + .../quickbuild/data/QuickBuildScratchTest.kt | 200 +++ .../cotg/quickbuild/service/Fakes.kt | 141 ++ .../provision/ProxyAppInstallerEdgeTest.kt | 76 ++ .../provision/ProxyAppInstallerTest.kt | 547 ++++++++ .../provision/QuickBuildClobberCheckTest.kt | 58 + .../session/QuickBuildDaemonControllerTest.kt | 225 ++++ 26 files changed, 6032 insertions(+) create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildDaemon.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildPaths.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayout.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildProvisioner.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/README.md create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreEdgeTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfoEdgeTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfoTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayoutTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchEdgeTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerEdgeTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheckTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonControllerTest.kt diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt new file mode 100644 index 0000000000..7eee9869d2 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt @@ -0,0 +1,559 @@ +package org.appdevforall.cotg.quickbuild.data + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic +import org.appdevforall.cotg.quickbuild.protocol.CompileStats +import org.appdevforall.cotg.quickbuild.protocol.DaemonOps +import org.appdevforall.cotg.quickbuild.protocol.DaemonResponse +import org.appdevforall.cotg.quickbuild.protocol.DexStats +import org.appdevforall.cotg.quickbuild.protocol.RequestKeys +import org.appdevforall.cotg.quickbuild.protocol.ResponseKeys +import org.slf4j.LoggerFactory +import java.io.BufferedWriter +import java.io.File +import java.io.IOException +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +/** + * Runs the quick-build daemon as a child JVM and speaks its line-delimited JSON protocol. + * + * Spawns the staged daemon jar on the bundled JDK and talks over stdin/stdout, all process I/O + * on [Dispatchers.IO] with one request in flight at a time ([requestMutex]) as the protocol + * requires. A watcher coroutine waits on the process: an exit without a preceding [shutdown] + * fails every pending request and fires the death listener, which the session manager turns + * into the Degraded/respawn flow. + * + * @property paths staged on-device locations - the JDK binary to spawn, the daemon jar (whose + * parent becomes the child's cwd), and the child's environment. + * @property scope coroutine scope the stdout pump, stderr drain, and death watcher run in; + * cancelling it abandons those readers but does not kill the child, which [shutdown] does. + * @property requestTimeoutMillis per-request ceiling in milliseconds, past which the call yields + * a [DaemonReply.Failed] rather than an exception and releases the request slot. + */ +class DaemonProcessClient( + private val paths: QuickBuildPaths, + private val scope: CoroutineScope, + private val requestTimeoutMillis: Long = DEFAULT_REQUEST_TIMEOUT_MILLIS, +) : QuickBuildDaemon { + private val requestMutex = Mutex() + private val nextId = AtomicLong(1) + private val pending = ConcurrentHashMap>() + + @Volatile private var process: Process? = null + + @Volatile private var writer: BufferedWriter? = null + + /** + * Deliberate-stop marker of the child [process] currently holds, replaced on every spawn + * rather than shared between them: a replaced child's watcher passes its identity guard and + * only then reads this, so a shared flag the next [start] had already cleared would report a + * death for a daemon that was deliberately replaced. + */ + @Volatile private var deliberateStop = AtomicBoolean(false) + + @Volatile private var deathListener: ((Int) -> Unit)? = null + + @Volatile private var configured = false + + @Volatile + override var scratchFsType: String? = null + private set + + override val isRunning: Boolean + get() = configured && process?.isAlive == true + + /** + * Installs the unexpected-exit callback, replacing any previous one. + * + * @param listener called with the child's exit code from the death-watcher coroutine, and + * only when no [shutdown] stopped that particular child - a later child's shutdown or + * start never suppresses it, and never causes it; null clears it. + */ + override fun setDeathListener(listener: ((Int) -> Unit)?) { + deathListener = listener + } + + /** + * Shuts down any running daemon, spawns a fresh child JVM, and sends `configure`. + * + * @param config the session-fixed settings sent in the `configure` request. + * @return [DaemonReply.Ok] once configure succeeded and the protocol version matched, else + * [DaemonReply.Failed] (spawn failure, protocol mismatch, or a rejected configuration) with + * the child shut down first, so a failed start never leaves a daemon behind. + */ + override suspend fun start(config: DaemonConfig): DaemonReply { + shutdown() + // A fresh marker instead of clearing the old one: the child shutdown() just stopped + // keeps - and its watcher still reads - the instance it was marked on. + val stopFlag = AtomicBoolean(false) + this.deliberateStop = stopFlag + // Belongs to the session being replaced; a failed configure must not leave the + // previous daemon's filesystem stamped on the next session's timings. + this.scratchFsType = null + + val proc = + try { + withContext(Dispatchers.IO) { + ProcessBuilder( + listOf( + paths.javaBinary.absolutePath, + "-jar", + paths.daemonJar.absolutePath, + ), + ).run { + redirectErrorStream(false) + directory(paths.daemonJar.parentFile) + // Do not inherit the app env: Android runtime classpath vars can + // abort a standalone OpenJDK on some OEM images. + environment().clear() + environment().putAll(paths.daemonEnvironment()) + start() + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.error("Failed to spawn quick-build daemon", e) + return DaemonReply.Failed("Failed to spawn daemon: ${e.message}", daemonDied = true) + } + + process = proc + writer = proc.outputStream.bufferedWriter() + startReaders(proc, stopFlag) + + val configureReply = + request(DaemonOps.CONFIGURE) { + addProperty(RequestKeys.PROJECT_ROOT, config.projectRoot.absolutePath) + add(RequestKeys.CLASSPATH, config.classpath.toJsonPaths()) + addProperty(RequestKeys.OUT_DIR, config.outDir.absolutePath) + addProperty(RequestKeys.AAPT2, config.aapt2.absolutePath) + addProperty(RequestKeys.D8_JAR, config.d8Jar.absolutePath) + addProperty(RequestKeys.ANDROID_JAR, config.androidJar.absolutePath) + addProperty(RequestKeys.MIN_API, config.minApi) + if (config.compilerPlugins.isNotEmpty()) { + add(RequestKeys.COMPILER_PLUGINS, config.compilerPlugins.toJsonPaths()) + } + } + val outcome = + when (configureReply) { + is DaemonReply.Ok -> { + val daemonVersion = + configureReply.value + .get(ResponseKeys.PROTOCOL_VERSION) + ?.takeIf { it.isJsonPrimitive } + ?.runCatching { asInt } + ?.getOrNull() + if (daemonVersion != EXPECTED_PROTOCOL_VERSION) { + // A missing field fails too: the daemon has stamped it into every + // configure success since the protocol existed, so absence means + // "not our daemon". + DaemonReply.Failed( + "Daemon protocol version mismatch: daemon reported " + + "${daemonVersion ?: "no protocolVersion"}, this client expects " + + "$EXPECTED_PROTOCOL_VERSION", + ) + } else { + scratchFsType = + configureReply.value + .get(ResponseKeys.SCRATCH_FS_TYPE) + ?.takeIf { it.isJsonPrimitive } + ?.asString + configured = true + DaemonReply.Ok(Unit) + } + } + + is DaemonReply.BuildFailed -> { + DaemonReply.Failed("Daemon rejected configuration", daemonDied = false) + } + + is DaemonReply.Failed -> { + configureReply + } + } + // A start that never reached a configured daemon must not leave the child behind: nothing + // else shuts it down, so it would hold its heap for the rest of the app's life and fire + // deathListener for a session that never had a daemon. + if (outcome !is DaemonReply.Ok) { + shutdown() + } + return outcome + } + + /** + * Sends one `compile` request and unpacks its classes dir, changed-class list, and timings. + * + * @param allSources every source file of the module, so the daemon can seed or re-seed its + * incremental caches. + * @param changedFiles the sources to treat as dirty this round. + * @param removedFiles sources deleted since the last build; omitted from the wire when + * empty, which keeps a daemon predating the field working. + * @return the compile output, or the daemon's diagnostics / transport failure unchanged, with + * [CompileOutput.changedClassFiles] null when the daemon omitted the signal. + */ + override suspend fun compile( + allSources: List, + changedFiles: List, + removedFiles: List, + ): DaemonReply { + val reply = + request(DaemonOps.COMPILE) { + add(RequestKeys.ALL_SOURCES, allSources.toJsonPaths()) + add(RequestKeys.CHANGED_FILES, changedFiles.toJsonPaths()) + if (removedFiles.isNotEmpty()) { + add(RequestKeys.REMOVED_FILES, removedFiles.toJsonPaths()) + } + } + val response = (reply as? DaemonReply.Ok)?.value + // Absent field (a daemon predating the signal) stays null - "unknown", which the + // deploy policy treats conservatively - distinct from an empty list ("nothing"). + val changed = + (response?.get(ResponseKeys.CLASSES_CHANGED) as? JsonArray) + ?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString } + return reply.mapFile(ResponseKeys.CLASSES_DIR).mapOk { + CompileOutput( + it, + changed, + kotlinMillis = response.longOrNull(ResponseKeys.KOTLIN_MILLIS), + javaMillis = response.longOrNull(ResponseKeys.JAVA_MILLIS), + stats = CompileStats.fromValues { key -> response.longOrNull(key) }, + ) + } + } + + /** + * Sends one `dex` request and unpacks the produced dex plus the pass's timings. + * + * @param classesDirs class-output directories to dex together, in the order the daemon + * should read them. + * @return the dex output, or the daemon's diagnostics / transport failure unchanged; a reply + * that omits `dexFile` is a [DaemonReply.Failed], never a guessed path. + */ + override suspend fun dex(classesDirs: List): DaemonReply { + val reply = + request(DaemonOps.DEX) { + add(RequestKeys.CLASSES_DIRS, classesDirs.toJsonPaths()) + } + val response = (reply as? DaemonReply.Ok)?.value + return reply.mapFile(ResponseKeys.DEX_FILE).mapOk { + DexOutput( + it, + stripMillis = response.longOrNull(ResponseKeys.STRIP_MILLIS), + d8Millis = response.longOrNull(ResponseKeys.D8_MILLIS), + stats = DexStats.fromValues { key -> response.longOrNull(key) }, + ) + } + } + + /** + * Sends one `relink` request, flattening [inputs] into the protocol's separate keys. + * + * @param inputs the relink contract; its optional stable-ids and library-resource fields + * are omitted from the wire when absent or empty. + * @return the relinked resource apk and aapt2 timings, or the daemon's diagnostics / + * transport failure unchanged. + */ + override suspend fun relink(inputs: RelinkInputs): DaemonReply { + val reply = + request(DaemonOps.RELINK) { + add(RequestKeys.RES_DIRS, inputs.resDirs.toJsonPaths()) + addProperty(RequestKeys.MANIFEST, inputs.manifest.absolutePath) + inputs.stableIdsFile?.let { addProperty(RequestKeys.STABLE_IDS, it.absolutePath) } + if (inputs.libraryResources.isNotEmpty()) { + add(RequestKeys.LIBRARY_RESOURCES, inputs.libraryResources.toJsonPaths()) + } + } + val response = (reply as? DaemonReply.Ok)?.value + return reply.mapFile(ResponseKeys.RESOURCES_ARSC).mapOk { + RelinkOutput( + it, + aapt2CompileMillis = response.longOrNull(ResponseKeys.AAPT2_COMPILE_MILLIS), + aapt2LinkMillis = response.longOrNull(ResponseKeys.AAPT2_LINK_MILLIS), + ) + } + } + + /** @return true when the daemon answered `ping` inside [requestTimeoutMillis]. */ + override suspend fun ping(): Boolean = request(DaemonOps.PING) {} is DaemonReply.Ok + + /** + * Stops the child politely, then forcibly, and clears the process handles. A no-op when + * nothing is running; the exit it causes is marked deliberate so no death listener fires. + */ + override suspend fun shutdown() { + val proc = process ?: return + // Marked before anything can kill it, so every exit from here on is deliberate to the + // watcher no matter how late it observes it. + deliberateStop.set(true) + configured = false + // Best effort polite stop; the protocol also treats stdin EOF as shutdown. + withTimeoutOrNull(SHUTDOWN_TIMEOUT_MILLIS) { request(DaemonOps.SHUTDOWN) {} } + withContext(Dispatchers.IO) { + runCatching { writer?.close() } + if (proc.isAlive && !proc.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)) { + proc.destroyForcibly() + } + } + process = null + writer = null + } + + /** + * Sends one request and awaits the matching-id response. Failure of the transport + * (dead process, EOF, timeout) is a [DaemonReply.Failed]; a well-formed + * `ok=false` response is a [DaemonReply.BuildFailed] with parsed diagnostics. + * + * @param op protocol op name, sent as `op` and echoed in timeout messages. + * @param fill adds the op's own keys to the request object; `id` and `op` are already set + * and must not be overwritten. + * @return the raw response object on success; holds [requestMutex] for the whole round-trip, + * so callers serialize automatically. + */ + private suspend fun request( + op: String, + fill: JsonObject.() -> Unit, + ): DaemonReply = + requestMutex.withLock { + val out = writer ?: return DaemonReply.Failed("Daemon is not running", daemonDied = true) + val id = nextId.getAndIncrement() + val deferred = CompletableDeferred() + pending[id] = deferred + + val requestJson = + JsonObject().apply { + addProperty(RequestKeys.ID, id) + addProperty(RequestKeys.OP, op) + fill() + } + + try { + withContext(Dispatchers.IO) { + out.write(requestJson.toString()) + out.newLine() + out.flush() + } + } catch (e: IOException) { + pending.remove(id) + return DaemonReply.Failed("Daemon write failed: ${e.message}", daemonDied = true) + } + + val response = + try { + withTimeoutOrNull(requestTimeoutMillis) { deferred.await() } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } finally { + pending.remove(id) + } + ?: return DaemonReply.Failed( + "Daemon did not answer '$op' (dead or timed out)", + daemonDied = process?.isAlive != true, + ) + + // Primitive-guarded like every other read: asBoolean on an object or array throws, + // and this facade promises never to throw for a build problem. + if (response.get(ResponseKeys.OK)?.takeIf { it.isJsonPrimitive }?.asBoolean == true) { + DaemonReply.Ok(response) + } else { + // fromValues yields null when the keys are absent, so a failing relink or dex - + // which reports no compile counts - carries none rather than a measured zero. + DaemonReply.BuildFailed( + parseDiagnostics(response), + CompileStats.fromValues { key -> + response.get(key)?.takeIf { it.isJsonPrimitive }?.asLong + }, + ) + } + } + + /** + * Launches the stdout response pump, the stderr log drain, and the process-death watcher. + * + * @param proc the freshly spawned child; all three coroutines live on [scope] and end when its + * streams close, so they need no separate cancellation. + * @param stopFlag [proc]'s own [deliberateStop] marker, closed over by the watcher so a later + * spawn's marker can never answer "was this exit deliberate?" for this child. + */ + private fun startReaders( + proc: Process, + stopFlag: AtomicBoolean, + ) { + scope.launch(Dispatchers.IO) { + try { + proc.inputStream.bufferedReader().forEachLine { line -> + val json = + runCatching { JsonParser.parseString(line).asJsonObject }.getOrNull() + // The id read needs the same guard as the parse: a non-numeric or nested + // id would throw out of forEachLine, killing this pump for the rest of + // the session. Every later request would then burn its full timeout and + // still see the process alive, so nothing would ever respawn the daemon. + val id = json?.get(ResponseKeys.ID)?.runCatching { asLong }?.getOrNull() + if (id == null) { + log.debug("daemon: {}", line) + return@forEachLine + } + pending.remove(id)?.complete(json) + ?: log.warn("Daemon response for unknown request id {}", id) + } + } catch (e: IOException) { + log.debug("Daemon stdout closed: {}", e.message) + } + } + scope.launch(Dispatchers.IO) { + try { + proc.errorStream.bufferedReader().forEachLine { line -> + log.warn("daemon(stderr): {}", line) + } + } catch (e: IOException) { + // stream closed with the process; nothing to do + } + } + scope.launch(Dispatchers.IO) { + val exitCode = runCatching { proc.waitFor() }.getOrDefault(-1) + // A child the respawn replaced dies asynchronously - destroyForcibly returns before + // the exit - so this can wake up after the NEXT child is already spawned. pending and + // configured below are shared across spawns, so touching them then would fail the new + // session's configure ("Daemon did not answer 'configure'"). + if (process !== proc) { + log.debug("Replaced quick-build daemon exited with code {}", exitCode) + return@launch + } + val abandoned = IOException("Daemon process exited (code $exitCode)") + pending.values.forEach { it.completeExceptionally(abandoned) } + pending.clear() + configured = false + // This child's own marker, not a shared flag - see [deliberateStop]. + if (!stopFlag.get()) { + log.error("Quick-build daemon died with exit code {}", exitCode) + deathListener?.invoke(exitCode) + } + } + } + + /** + * Reads the `diagnostics` array off a failed response. + * + * @param response the `ok=false` response object. + * @return one [BuildDiagnostic] per well-formed entry, empty when the key is absent or not an + * array; anything but an explicit `WARNING` reads as an error and a missing message becomes + * "unknown error", so a diagnostic is never dropped for being thin. + */ + private fun parseDiagnostics(response: JsonObject): List { + val array = response.get(ResponseKeys.DIAGNOSTICS) as? JsonArray ?: return emptyList() + return array.mapNotNull { element -> + val obj = element as? JsonObject ?: return@mapNotNull null + BuildDiagnostic( + severity = + if (obj.get(ResponseKeys.Diagnostics.SEVERITY)?.asString.equals("WARNING", ignoreCase = true)) { + BuildDiagnostic.Severity.WARNING + } else { + BuildDiagnostic.Severity.ERROR + }, + message = obj.get(ResponseKeys.Diagnostics.MESSAGE)?.asString ?: "unknown error", + file = obj.get(ResponseKeys.Diagnostics.FILE)?.takeIf { it.isJsonPrimitive }?.asString, + line = obj.get(ResponseKeys.Diagnostics.LINE)?.takeIf { it.isJsonPrimitive }?.asInt, + column = obj.get(ResponseKeys.Diagnostics.COLUMN)?.takeIf { it.isJsonPrimitive }?.asInt, + ) + } + } + + /** + * Extracts an output file path from an op response. The key is mandatory: a conventional + * fallback under `outDir` resolves whatever the previous build left there, so the client + * would dex and deploy stale artifacts and report success with the user's edit missing, and + * the protocol does not bump its version for a key rename + * ([DaemonResponse.PROTOCOL_VERSION]), so nothing else catches that drift. + * + * @param field response key holding the path; the daemon has written it on every `ok` + * response for this op since the op existed. + * @return the resolved file, the non-Ok reply unchanged, or a fresh [DaemonReply.Failed] + * naming [field] when the key is absent, non-primitive or empty. + */ + private fun DaemonReply.mapFile(field: String): DaemonReply = + when (this) { + is DaemonReply.Ok -> { + val path = + value + .get(field) + ?.takeIf { it.isJsonPrimitive } + ?.asString + ?.takeIf { it.isNotEmpty() } + if (path == null) { + DaemonReply.Failed("Daemon reply missing '$field'") + } else { + DaemonReply.Ok(File(path)) + } + } + + is DaemonReply.BuildFailed -> { + this + } + + is DaemonReply.Failed -> { + this + } + } + + /** + * Optional numeric field: null when absent or non-primitive (a pre-timing daemon). + * + * @param field response key to read. + * @return the value as a Long, or null - including when the receiver itself is null, so a + * non-Ok reply needs no separate guard. + */ + private fun JsonObject?.longOrNull(field: String): Long? = + this + ?.get(field) + ?.takeIf { it.isJsonPrimitive } + ?.runCatching { asLong } + ?.getOrNull() + + /** + * Rewraps a success value, leaving both failure arms alone. + * + * @param transform applied only to a [DaemonReply.Ok] value; must not throw, since nothing + * here converts an exception into a reply. + * @return the transformed Ok, or this same failure reply. + */ + private fun DaemonReply.mapOk(transform: (T) -> R): DaemonReply = + when (this) { + is DaemonReply.Ok -> DaemonReply.Ok(transform(value)) + is DaemonReply.BuildFailed -> this + is DaemonReply.Failed -> this + } + + /** @return a JSON array of absolute paths, order preserved - the wire form for file lists. */ + private fun List.toJsonPaths(): JsonArray = JsonArray().also { array -> forEach { array.add(it.absolutePath) } } + + companion object { + private val log = LoggerFactory.getLogger("QB-DaemonClient") + + /** + * The wire-protocol version this client speaks, shared with the daemon via + * [DaemonResponse.PROTOCOL_VERSION]. [start] rejects a configure reply whose version + * differs or is absent, so drift fails at session start rather than as misparsed + * replies mid-build - a staged daemon jar older than this client is exactly that case. + */ + const val EXPECTED_PROTOCOL_VERSION = DaemonResponse.PROTOCOL_VERSION + + /** Compile of a large changeset can be slow on low-spec; be generous. */ + const val DEFAULT_REQUEST_TIMEOUT_MILLIS = 300_000L + + private const val SHUTDOWN_TIMEOUT_MILLIS = 3_000L + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt new file mode 100644 index 0000000000..a27b15f6db --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt @@ -0,0 +1,72 @@ +package org.appdevforall.cotg.quickbuild.data + +import org.appdevforall.cotg.quickbuild.domain.reload.GenerationStore +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException + +/** + * Keeps the generation counter in `/.androidide/quickbuild/generation`. + * + * Lives with the project rather than in the app-private [QuickBuildScratch] tree because + * scratch is deleted on session teardown while this counter must outlive sessions: an + * installed proxy app keys its payloads by generation, so only a surviving counter lets a + * later session stay strictly newer. A corrupt or unreadable file loads as null (fresh + * session), so a broken state file cannot take quick build down. + * + * @property file the counter file; it need not exist yet, its parent directory is created on + * first [save], and a sibling `.tmp` is the write staging path. + */ +class FileGenerationStore( + private val file: File, +) : GenerationStore { + /** + * Reads the persisted counter. + * + * @return the stored generation, or null when the file is missing, unreadable, or does not + * parse as a Long - all of which the caller treats as a fresh session. + */ + override fun load(): Long? = + try { + if (file.isFile) file.readText().trim().toLongOrNull() else null + } catch (e: IOException) { + log.warn("Failed to read generation from {}; starting fresh", file, e) + null + } + + /** + * Persists the counter atomically via temp file plus rename. + * + * @param generation the value to store; the caller guarantees it is strictly greater than + * any previously saved one, since the installed proxy app keys its payloads by it. + * @throws IOException when the value could not be persisted, including the second rename + * attempt after clearing the destination; unlike [load] this is never swallowed, since + * losing it would let a later session reuse a generation. + */ + override fun save(generation: Long) { + file.parentFile?.mkdirs() + val tmp = File(file.parentFile, file.name + ".tmp") + tmp.writeText(generation.toString()) + if (!tmp.renameTo(file)) { + // Windows-style rename-over-existing failure path; harmless on device but + // keeps the store correct wherever the JVM tests run. + file.delete() + if (!tmp.renameTo(file)) { + throw IOException("Unable to persist generation $generation to $file") + } + } + } + + companion object { + private val log = LoggerFactory.getLogger("QB-GenerationStore") + + /** + * Builds a store at the canonical per-project location of the generation file. + * + * @param projectRoot the user project's root directory; the file lands at + * `.androidide/quickbuild/generation` beneath it, and neither need exist yet. + * @return a store for that path; no filesystem access happens until [load] or [save]. + */ + fun forProject(projectRoot: File): FileGenerationStore = FileGenerationStore(File(projectRoot, ".androidide/quickbuild/generation")) + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt new file mode 100644 index 0000000000..ebc20ed02b --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt @@ -0,0 +1,301 @@ +package org.appdevforall.cotg.quickbuild.data + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentInfo +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind +import org.appdevforall.cotg.quickbuild.protocol.ConfigureRequest +import org.slf4j.LoggerFactory +import java.io.File + +/** + * What the proxy app build published about the project, read from its output manifest + * `build/quickbuild/setup.json`. + * + * [parse] accepts several key aliases per field (primary name first) because the names are a + * convention shared with the Gradle-plugin writer rather than an enforced schema. + */ +data class ProxyAppInfo( + /** The generated proxy app's applicationId - the project's real applicationId. */ + val proxyAppPackage: String, + /** + * Fully-qualified user entry activity, carried in every deploy metadata. Null when the + * proxy app build found no launchable Activity (e.g. the No-Activity template) - a + * successful build with nothing to install and launch, which + * [org.appdevforall.cotg.quickbuild.service.provision.QuickBuildProvisioner] callers must refuse + * with a friendly message rather than let through as a success. + */ + val entryActivity: String?, + /** The built proxy-app APK to install. */ + val apk: File, + /** Compile classpath for the daemon; optional in the JSON. */ + val classpath: List, + /** + * Compiled proxy classes from the proxy app build; the executor bundles them into + * every payload dex (the proxies must ride with the user classes they extend). + * Optional in the JSON. + */ + val proxyClassesDir: File?, + /** + * The proxy app build's transformed manifest (proxy-app package plus proxy component + * names); resource relinks must link against it, not the user's raw manifest. Optional + * in the JSON. + */ + val transformedManifest: File?, + /** + * True when the proxy app build detected Jetpack Compose in the user project; the + * daemon then compiles with the bundled Compose compiler plugin. Optional in the + * JSON, defaults to false. + */ + val composeEnabled: Boolean = false, + /** + * setup.json schema version; 0 when the field is absent (a pre-v2 baseline). + * Schema >= 2 means the baseline carries [components] and its baked runtime + * understands restart deploys - the deploy policy's skew guard keys on this. + */ + val schema: Int = 0, + /** + * The manifest components the proxy app build recorded (schema v2 `components`); + * empty for pre-v2 baselines. Feeds the restart closure and the relaunch target. + */ + val components: List = emptyList(), + /** + * KSP/kapt/annotationProcessor coordinates the proxy app build saw. Empty (or absent, on + * an older setup.json) means no processors, and the classifier stays in its original + * content-free mode; non-empty switches on annotation-aware classification. + */ + val annotationProcessors: List = emptyList(), + /** + * Every java/kotlin source root of the built variant, GENERATED roots included. The + * layout adds these to the daemon's source set so processor output compiles alongside + * user code. Absent on an older setup.json, where only the convention roots apply. + */ + val sourceRoots: List = emptyList(), + /** + * AGP's `stableIds.txt` from the proxy app build (`setup.json` `stableIdsPath`), which + * lets relinks pin resource ids against the baseline. Null on an older setup.json or a + * build whose AGP version/variant never produced the file. + */ + val stableIdsFile: File? = null, + /** + * Pre-compiled `.flat` resource units from the proxy app build (`setup.json` + * `libraryResourcePaths`) - the merged_res closure plus every resource-providing AAR - + * which let relinks resolve resources a dependency AAR provides. Empty on an older + * setup.json or a build whose AGP version/variant never produced them. + */ + val libraryResourceFlats: List = emptyList(), + /** + * The API level the proxy app build dexed the seed payload at (`setup.json` `minApi`) - + * `max(the project's minSdk, the Quick Build floor)`. Every increment the daemon dexes + * patches that baseline, so it must use the same level. Falls back to + * [ConfigureRequest.DEFAULT_MIN_API] on an older setup.json that carries no such key, which + * is what the daemon assumed unconditionally before the field existed. + */ + val minApi: Int = ConfigureRequest.DEFAULT_MIN_API, +) { + /** True when [schema] is at least [COMPONENT_SCHEMA_VERSION]. */ + val supportsComponentInfo: Boolean + get() = schema >= COMPONENT_SCHEMA_VERSION + + companion object { + private val log = LoggerFactory.getLogger("QB-ProxyAppInfo") + + /** + * The setup.json schema version that introduced `components` and runtime restart + * support. Bump together with the writer side's `QuickBuildJson.SCHEMA_VERSION` + * (gradle-plugin quickbuild/QuickBuildJson.kt). + */ + const val COMPONENT_SCHEMA_VERSION = 2 + + /** + * Parses a setup.json document. + * + * @param json the raw file contents; anything that is not a JSON object is a parse + * failure rather than a throw. + * @param baseDir directory the JSON's relative paths resolve against (the project root). + * @return the parsed info, or null when the JSON is malformed or misses a required + * field - provisioning then fails visibly instead of crashing. + */ + fun parse( + json: String, + baseDir: File, + ): ProxyAppInfo? { + val obj = + runCatching { JsonParser.parseString(json).asJsonObject }.getOrNull() + ?: run { + log.error("setup.json is not a JSON object") + return null + } + + val pkg = + // "testAppId"/"testAppPackage" are legacy aliases: a setup.json already on + // device may predate the proxy-app vocabulary rename. + obj.firstString("proxyAppId", "testAppId", "testAppPackage", "applicationId", "packageName") + ?: return missing("proxyAppId") + // Absent or an explicit JSON null (the plugin writes `"entryActivity": null` for + // a project with no launchable Activity) is a legitimate successful build, not a + // parse failure - see [ProxyAppInfo.entryActivity]. + val entry = obj.firstString("entryActivity", "mainActivity") + val apkPath = obj.firstString("apk", "apkPath", "apkFile") ?: return missing("apk") + + val classpath = + obj + .getAsJsonArray("classpath") + ?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString } + ?.map { resolve(it, baseDir) } + ?: emptyList() + // Generated project-scope jars (R.jar and kin) ride the compile classpath: + // hot compiles reference R, which the variant compile classpath lacks. + val payloadJars = + obj + .getAsJsonArray("payloadJars") + ?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString } + ?.map { resolve(it, baseDir) } + ?: emptyList() + + return ProxyAppInfo( + proxyAppPackage = pkg, + entryActivity = entry, + apk = resolve(apkPath, baseDir), + classpath = classpath + payloadJars, + proxyClassesDir = obj.firstString("proxyClassesDir")?.let { resolve(it, baseDir) }, + transformedManifest = + obj + .firstString("manifestPath", "transformedManifest") + ?.let { resolve(it, baseDir) }, + composeEnabled = + obj + .get("composeEnabled") + ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isBoolean } + ?.asBoolean == true, + schema = + obj + .get("schema") + ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber } + ?.asInt ?: 0, + components = + obj + .getAsJsonArray("components") + ?.mapNotNull { element -> (element as? JsonObject)?.let(::parseComponent) } + ?: emptyList(), + annotationProcessors = obj.stringArray("annotationProcessors"), + sourceRoots = obj.stringArray("sourceRoots").map { resolve(it, baseDir) }, + stableIdsFile = obj.firstString("stableIdsPath")?.let { resolve(it, baseDir) }, + libraryResourceFlats = obj.stringArray("libraryResourcePaths").map { resolve(it, baseDir) }, + // Absent (older setup.json) or an explicit null both fall back to the + // protocol default - the level the daemon used before this was published. + minApi = + obj + .get("minApi") + ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber } + ?.asInt ?: ConfigureRequest.DEFAULT_MIN_API, + ) + } + + /** + * A JSON array of strings; empty when the key is absent or not an array. + * + * @param key the array-valued key to read. + * @return its string elements in document order, with non-primitive and blank entries + * dropped rather than treated as an error. + */ + private fun JsonObject.stringArray(key: String): List = + getAsJsonArray(key) + ?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString } + ?.filter { it.isNotBlank() } + ?: emptyList() + + /** + * One `components` entry; null (skipped, logged) when malformed or of an unknown type. + * + * @param obj the array element to read, expected to carry at least `type` and + * `userClass`. + * @return the parsed component, or null to skip it - a missing required field is + * silent, an unrecognized `type` is logged, and neither fails the whole parse. + */ + private fun parseComponent(obj: JsonObject): ComponentInfo? { + val typeName = obj.firstString("type") ?: return null + val kind = + when (typeName) { + "activity" -> { + ComponentKind.ACTIVITY + } + + "service" -> { + ComponentKind.SERVICE + } + + "receiver" -> { + ComponentKind.RECEIVER + } + + "provider" -> { + ComponentKind.PROVIDER + } + + "application" -> { + ComponentKind.APPLICATION + } + + else -> { + // A future schema's component type this build doesn't know. The + // schema version, not this parser, is the compatibility gate. + log.warn("setup.json component of unknown type '{}' ignored", typeName) + return null + } + } + val userClass = obj.firstString("userClass") ?: return null + return ComponentInfo( + kind = kind, + className = userClass, + proxyClass = obj.firstString("proxyClass"), + launcher = + obj + .get("launcher") + ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isBoolean } + ?.asBoolean == true, + supertypes = + obj + .getAsJsonArray("supertypes") + ?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString } + ?: emptyList(), + ) + } + + /** + * Interprets one path from the JSON. + * + * @param path an absolute path, or one relative to [baseDir]. + * @param baseDir the project root relative paths hang off. + * @return the resolved file, never checked for existence - a missing input has to surface + * where it is used, with that step's context. + */ + private fun resolve( + path: String, + baseDir: File, + ): File = File(path).let { if (it.isAbsolute) it else File(baseDir, path) } + + /** + * Reads the first key that carries a usable string, which is how the parser accepts + * legacy aliases for a renamed field. + * + * @param keys candidate key names, most preferred first. + * @return the first non-blank primitive value found, or null when no key yields one. + */ + private fun JsonObject.firstString(vararg keys: String): String? = + keys.firstNotNullOfOrNull { key -> + get(key)?.takeIf { it.isJsonPrimitive }?.asString?.takeIf { it.isNotBlank() } + } + + /** + * Logs a required-field failure at the one call shape [parse] uses to bail out. + * + * @param field the primary key name to name in the log, not the alias that was tried. + * @return always null, so the caller can `return missing(...)` in one line. + */ + private fun missing(field: String): ProxyAppInfo? { + log.error("setup.json is missing required field '{}'", field) + return null + } + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildDaemon.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildDaemon.kt new file mode 100644 index 0000000000..44e2663467 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildDaemon.kt @@ -0,0 +1,246 @@ +package org.appdevforall.cotg.quickbuild.data + +import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic +import org.appdevforall.cotg.quickbuild.protocol.CompileStats +import org.appdevforall.cotg.quickbuild.protocol.ConfigureRequest +import org.appdevforall.cotg.quickbuild.protocol.DexStats +import java.io.File + +/** + * Typed facade over the warm compile daemon (protocol: quickbuild/README.md). + * + * An interface so the executor and session manager can be tested against scripted fakes; + * [DaemonProcessClient] is the real child-JVM implementation. Mirrors the daemon protocol: + * one request in flight at a time, and no method throws for build problems - every outcome + * is a [DaemonReply]. + */ +interface QuickBuildDaemon { + /** True while the daemon process is alive and configured. */ + val isRunning: Boolean + + /** + * Filesystem type of the daemon's scratch tree (`ext4`, `f2fs`, `fuse`, ...) as reported at + * `configure`; null before a successful configure or from a daemon predating the field. + * Session-constant, so it is read once per build rather than carried on every reply. + * Recorded alongside build timings because it predicts them: per-file work costs ~52x more + * on FUSE-backed emulated storage than on the app's own filesystem (measured for ADFA-4128). + */ + val scratchFsType: String? + get() = null + + /** + * Spawns (or respawns) the daemon process and sends `configure`. A running daemon is + * shut down first, so this is also the respawn path after a death. + * + * @param config the session-fixed settings; the implementation may retain it for the + * lifetime of the process, so callers must not mutate the files it names mid-session. + * @return [DaemonReply.Ok] once the daemon is configured and ready for ops, else + * [DaemonReply.Failed] - a spawn or configure problem is infrastructure, never a + * [DaemonReply.BuildFailed]. + */ + suspend fun start(config: DaemonConfig): DaemonReply + + /** + * Compiles the project incrementally. [changedFiles] must be the known changed set; + * pass all sources as changed to seed the incremental caches. + * + * @param allSources every `.kt`/`.java` in scope this session, not just the dirty ones - + * the daemon needs the full set to resolve references and to prune its caches. + * @param changedFiles the sources to treat as dirty; a subset of [allSources]. + * @param removedFiles sources deleted since the last build, so their outputs are removed and + * dependents recompiled (a removed `.java`'s stale `.class` is deleted explicitly, since + * javac has no incremental removed-files API); may be empty. + * @return the compiled classes dir plus the .class files this run emitted. + */ + suspend fun compile( + allSources: List, + changedFiles: List, + removedFiles: List = emptyList(), + ): DaemonReply + + /** + * Dexes [classesDirs] into one `classes.dex`, with the daemon's step timings. + * + * @param classesDirs class-output directories to merge into the single dex, in the order + * they should be read; typically the compile output plus the proxy classes. + * @return the produced dex plus timings, or the failure arm the op ended in. + */ + suspend fun dex(classesDirs: List): DaemonReply + + /** + * Relinks the project resources with aapt2; see [RelinkInputs] for the input contract. + * + * @param inputs the res dirs, manifest, and optional baseline pinning inputs for this + * relink, bundled so the signature stops growing. + * @return the full relinked resource apk (resources.arsc plus every compiled resource + * file), not a bare extracted table - a bare table cannot back a file-typed resource. + */ + suspend fun relink(inputs: RelinkInputs): DaemonReply + + /** + * Liveness probe; false when the daemon is missing or unresponsive. + * + * @return true only on an answered `ping`, which takes the same one-at-a-time request slot as + * a build op and so can queue behind an in-flight compile rather than answering at once. + */ + suspend fun ping(): Boolean + + /** Graceful stop; a subsequent exit is deliberate, not a death. */ + suspend fun shutdown() + + /** + * Registers a callback for the daemon exiting without a shutdown request. The session + * manager routes it into [org.appdevforall.cotg.quickbuild.domain.session.SessionEvent.DaemonDied]. + * + * @param listener receives the process exit code on the implementation's own thread, never + * for an exit [shutdown] asked for; null clears the single listener held. + */ + fun setDeathListener(listener: ((exitCode: Int) -> Unit)?) +} + +/** + * A successful `compile` op's output. + * + * @property classesDir directory containing the compiled classes. + * @property changedClassFiles the .class files this run emitted or rewrote, '/'-separated + * relative to [classesDir] - the deploy policy's recompiled-set signal, null when the daemon + * did not report it, which makes the policy decide conservatively (restart over stale). + * @property kotlinMillis wall time of the daemon's Kotlin pass; null when unreported, as for + * every step-timing field below. + * @property javaMillis wall time of the daemon's javac pass. + * @property stats the phases [kotlinMillis]/[javaMillis] do not cover (output-tree + * snapshots, the Java-ABI re-parse) plus this build's counts. + */ +data class CompileOutput( + val classesDir: File, + val changedClassFiles: List?, + val kotlinMillis: Long? = null, + val javaMillis: Long? = null, + val stats: CompileStats? = null, +) + +/** + * A successful `dex` op's output: the produced `classes.dex` plus the daemon's step + * timings (null when unreported by a pre-timing daemon). + * + * @property dexFile the single `classes.dex` this op produced, ready to stage into a payload. + * @property stripMillis wall time of the daemon's class-stripping pass; null when unreported. + * @property d8Millis wall time of the d8 invocation itself; null when unreported. + * @property stats how many classes / bytes the pass moved; null when unreported. + */ +data class DexOutput( + val dexFile: File, + val stripMillis: Long? = null, + val d8Millis: Long? = null, + val stats: DexStats? = null, +) + +/** + * The `relink` op's inputs, bundled into one value so the executor -> facade -> client chain + * stops accreting positional parameters. Pure carrier: [DaemonProcessClient] still + * serializes each field as its own protocol key. + * + * @property resDirs the project's own `res/` directories to recompile and relink. + * @property manifest the manifest to link against - the proxy app build's transformed + * manifest when available, else the project's raw one. + * @property stableIdsFile AGP's stable-ids mapping from the proxy app build + * ([QuickBuildProjectLayout.stableIdsFile]), pinning ids so relinking the project's own res/ - + * a strict subset of what the real build merged - cannot shift an id out from under the + * already-compiled manifest; null relinks unpinned. + * @property libraryResources pre-compiled `.flat` resource units from the proxy app build + * ([QuickBuildProjectLayout.libraryResourceFlats]), letting a relink resolve resources the + * project's own res/ never declares (Material3's `Theme.Material3.DayNight.NoActionBar` and + * kin); empty relinks against the project's own res/ alone. + */ +data class RelinkInputs( + val resDirs: List, + val manifest: File, + val stableIdsFile: File? = null, + val libraryResources: List = emptyList(), +) + +/** + * A successful `relink` op's output: the full relinked resource apk plus the daemon's + * step timings (null when unreported by a pre-timing daemon). + * + * @property resourceApk the relinked apk - resources.arsc plus every compiled resource file, + * not a bare table, since a bare table cannot back a file-typed resource. + * @property aapt2CompileMillis wall time of the aapt2 compile pass; null when unreported. + * @property aapt2LinkMillis wall time of the aapt2 link pass; null when unreported. + */ +data class RelinkOutput( + val resourceApk: File, + val aapt2CompileMillis: Long? = null, + val aapt2LinkMillis: Long? = null, +) + +/** + * Everything the daemon needs to know once per session (`configure` op). + * + * @property projectRoot the user project's root directory, which anchors the daemon's own + * relative bookkeeping. + * @property classpath compile classpath: the variant's library jars/AARs plus the proxy app + * build's generated jars (R.jar and kin), which hot compiles reference. + * @property outDir directory the daemon writes classes, dex, and relinked resources under; it + * is also the base for the conventional output paths a reply may omit. + * @property aapt2 on-device aapt2 binary used for resource compile and link. + * @property d8Jar d8/r8 jar the daemon dexes with, in-process. + * @property androidJar `android.jar` of the bundled compile SDK, the bootclasspath for compiles. + * @property compilerPlugins session-fixed Kotlin compiler plugin jars (-Xplugin), such as Compose. + * @property minApi API level the daemon dexes at, taken from the proxy app build's setup.json so + * increments are desugared exactly like the baseline they patch. Defaults to the protocol floor, + * which is what an older setup.json (carrying no such field) means. + */ +data class DaemonConfig( + val projectRoot: File, + val classpath: List, + val outDir: File, + val aapt2: File, + val d8Jar: File, + val androidJar: File, + val compilerPlugins: List = emptyList(), + val minApi: Int = ConfigureRequest.DEFAULT_MIN_API, +) + +/** + * Result of one daemon op. [BuildFailed] is the user's code failing to build (maps to + * [org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome.CompileError]); [Failed] is + * the pipeline itself breaking (daemon dead, protocol I/O error) and maps to + * [org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome.InfrastructureFailure]. + */ +sealed interface DaemonReply { + /** + * The op succeeded. + * + * @property value the op's output; [Unit] for ops that only report success. + */ + data class Ok( + val value: T, + ) : DaemonReply + + /** + * The user's code failed to build - the pipeline itself is healthy and the daemon stays up. + * + * @property diagnostics compiler errors and warnings to show the user, in the order the + * daemon reported them; empty when it failed without saying why. + * @property stats the failing compile's counts, or null when the op was not a compile or the + * daemon answered without them. A failing build is the one whose counts matter most: + * `kotlinToCompile` says whether the edit reached the dirty set the engine was handed. + */ + data class BuildFailed( + val diagnostics: List, + val stats: CompileStats? = null, + ) : DaemonReply + + /** + * The pipeline itself broke; nothing can be said about the user's code. + * + * @property message operator-facing reason, safe to log but not written for end users. + * @property daemonDied true when the child process is gone or presumed gone, which is the + * session manager's signal to respawn rather than retry. + */ + data class Failed( + val message: String, + val daemonDied: Boolean = false, + ) : DaemonReply +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildPaths.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildPaths.kt new file mode 100644 index 0000000000..ce3e0972a7 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildPaths.kt @@ -0,0 +1,60 @@ +package org.appdevforall.cotg.quickbuild.data + +import java.io.File + +/** + * Filesystem locations the quick-build pipeline needs on device. + * + * An interface so the module stays free of CoGo's `:common` Environment singleton and unit + * tests can point everything at temp directories. The app-side stager re-extracts the + * `/quickbuild/` layout from APK assets on every provision, so a stale bundle + * can never be served. + */ +interface QuickBuildPaths { + /** The bundled JDK's `java` binary (same discovery the tooling server uses). */ + val javaBinary: File + + /** + * The staged daemon jar; the process runs with this jar's dir as cwd, and the jar's manifest + * Class-Path names sibling jars, so the whole runtime classpath is staged beside it. + */ + val daemonJar: File + + /** The staged runtime AAR handed to the proxy app build. */ + val runtimeAar: File + + /** On-device aapt2 (CoGo's Android-built binary, not the Maven one). */ + val aapt2: File + + /** d8/r8 jar for the daemon's in-process dexing. */ + val d8Jar: File + + /** + * The Compose compiler plugin jar staged next to the daemon jar, version-matched to the + * daemon's bundled Kotlin compiler - not the user project's Compose compiler, whose + * version tracks the project's own Kotlin. Passed as -Xplugin when the proxy app build + * reports the project uses Compose. + */ + val composeCompilerPlugin: File + + /** `android.jar` of the bundled compile SDK. */ + val androidJar: File + + /** + * Root for per-project scratch trees ([QuickBuildScratch]) on app-private, ext4-backed + * storage - not under the project on `/storage/emulated`, whose FUSE layer costs ~50x + * per file on this intermediate-heavy path (ADFA-4930). The app wires a + * `Context.noBackupFilesDir` subtree. + */ + val projectScratchRoot: File + + /** + * Builds the full environment for the daemon child process. The host app env must not be + * inherited: Android runtime classpath vars crash a standalone OpenJDK on some OEM images + * (the same reason ToolingServerRunner clears its env). + * + * @return the complete environment for the child - callers replace rather than merge, so + * anything the daemon needs (`HOME`, `PATH`, `TMPDIR`, ...) has to be in here. + */ + fun daemonEnvironment(): Map +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayout.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayout.kt new file mode 100644 index 0000000000..f7758b94ac --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayout.kt @@ -0,0 +1,159 @@ +package org.appdevforall.cotg.quickbuild.data + +import java.io.File + +/** + * What the quick path needs to know about the user project's shape. + * + * Convention-based, for the standard single-app-module project the templates emit: sources in + * `src/main/{java,kotlin}`, resources in `src/main/res`, assets in `src/main/assets`. Pure + * `File` arithmetic over those conventions, so tests build one over a temp dir rather than + * faking it. + * + * @property projectRoot the user project's root directory, which the watched gradle config + * files and the module scan hang off. + * @property appModuleDir the single app module's directory, whose `src/main` supplies every + * convention path and which is treated as a module even if the scan misses it. + * @property classpath compile classpath handed straight to [compileClasspath], unmodified. + * @property extraSourceRoots extra source roots from the proxy app build (the KSP/kapt generated + * roots, without which an annotation-processing project cannot hot-compile at all), compiled + * but deliberately not watched because Gradle owns `build/`. + * @property stableIdsFile AGP's `stableIds.txt`, passed to `aapt2 link --stable-ids` so aapt2's + * type-index assignment cannot drift when a baseline resource type is absent from the relink; + * null when the proxy app build reported none, which relinks unpinned. + * @property libraryResourceFlats pre-compiled `.flat` resource units from the proxy app build, + * passed to `aapt2 link` as `-R` overlays so a relink can resolve a resource only a dependency + * AAR declares (e.g. Material3's `Theme.Material3.DayNight.NoActionBar`). + */ +class QuickBuildProjectLayout( + val projectRoot: File, + private val appModuleDir: File = File(projectRoot, "app"), + private val classpath: List = emptyList(), + private val extraSourceRoots: List = emptyList(), + private val stableIdsFile: File? = null, + private val libraryResourceFlats: List = emptyList(), +) { + private val mainDir = File(appModuleDir, "src/main") + + /** + * Every `.kt`/`.java` under the app module's main source roots: `src/main/java`, + * `src/main/kotlin`, and [extraSourceRoots]. + * + * @return existing `.kt`/`.java` files, deduplicated (the roots can overlap) and sorted so the + * daemon sees a stable order; walks the filesystem on each call, so hold it for a build. + */ + fun allSources(): List = + (listOf(File(mainDir, "java"), File(mainDir, "kotlin")) + extraSourceRoots) + .map { it.absoluteFile.normalize() } + .distinct() + .filter { it.isDirectory } + .flatMap { root -> + root.walkTopDown().filter { it.isFile && (it.extension == "kt" || it.extension == "java") } + }.distinct() + .sorted() + + /** + * The app module's resource directories, to recompile and relink. + * + * @return `src/main/res` when it exists, else empty - a project may legitimately have none. + */ + fun resDirs(): List = listOf(File(mainDir, "res")).filter { it.isDirectory } + + /** + * The app module's asset roots, whose files ship in the payload zip. + * + * @return `src/main/assets`, listed whether or not it exists - it is a prefix for matching + * changed files, not a directory to walk. + */ + fun assetRoots(): List = listOf(File(mainDir, "assets")) + + /** + * The app module's `AndroidManifest.xml`. + * + * @return `src/main/AndroidManifest.xml`, unchecked; a relink surfaces a missing manifest + * with aapt2's own error. + */ + fun manifest(): File = File(mainDir, "AndroidManifest.xml") + + /** + * Compile classpath for the daemon (library jars/AARs' classes). + * + * @return the [classpath] given at construction, order preserved - it matters for duplicate + * classes. + */ + fun compileClasspath(): List = classpath + + /** @return the [stableIdsFile] given at construction; null when none was reported. */ + fun stableIdsFile(): File? = stableIdsFile + + /** @return the [libraryResourceFlats] given at construction; empty when none were reported. */ + fun libraryResourceFlats(): List = libraryResourceFlats + + /** + * Roots the watch filter accepts events under (src/res/assets). Every module's `src`, not + * just the app module's: a library edit must be seen so it rebaselines, rather than firing + * no event and silently not reloading. The classifier still live-reloads only + * [liveReloadScope]; other-module edits route to a full build. + * + * @return one `src` per discovered module, existing or not - the watcher skips the misses. + */ + fun watchedRoots(): List = moduleDirs().map { File(it, "src") } + + /** + * Exact files watched outside the roots (gradle config; changes invalidate). + * + * @return the root's settings/properties/version-catalog files plus both build-script + * spellings for every module, listed unconditionally - only the existing ones are polled. + */ + fun watchedFiles(): List = + listOf( + File(projectRoot, "settings.gradle"), + File(projectRoot, "settings.gradle.kts"), + File(projectRoot, "gradle.properties"), + File(projectRoot, "gradle/libs.versions.toml"), + ) + + moduleDirs().flatMap { + listOf(File(it, "build.gradle"), File(it, "build.gradle.kts")) + } + + /** + * The source scope the live reload path can build incrementally - the app module's. A + * watched change outside it belongs to another module and must go through a proxy app + * rebuild (see [org.appdevforall.cotg.quickbuild.domain.classify.ChangeClassifier]). + * + * @return the app module's `src` alone; a change under none of them routes to a full build. + */ + fun liveReloadScope(): List = listOf(File(appModuleDir, "src")) + + /** + * Finds every Gradle module dir (one holding a `build.gradle[.kts]`) by a shallow walk, + * always including the app module. Skips `build/` and hidden dirs, and bounds depth to + * keep the one-time session-start scan cheap. Errs toward including too much: a spurious + * module only costs a rebaseline, while a missed one silently drops its edits. + * + * @return the app module first, then each directory found, deduplicated; modules nested + * deeper than [MODULE_SCAN_MAX_DEPTH] are simply absent. + */ + private fun moduleDirs(): List { + val dirs = LinkedHashSet() + dirs.add(appModuleDir) + projectRoot + .walkTopDown() + .maxDepth(MODULE_SCAN_MAX_DEPTH) + .onEnter { it.name != "build" && !it.name.startsWith(".") } + .forEach { + if (it.isDirectory && + (File(it, "build.gradle").isFile || File(it, "build.gradle.kts").isFile) + ) { + dirs.add(it) + } + } + return dirs.toList() + } + + private companion object { + // `:a:b:c:d`-deep module paths are rare; a deeper reactor just watches less of its + // tail, which stays correct - those edits are outside the live reload path anyway. + const val MODULE_SCAN_MAX_DEPTH = 4 + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt new file mode 100644 index 0000000000..7b56eb5e56 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt @@ -0,0 +1,188 @@ +package org.appdevforall.cotg.quickbuild.data + +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage +import org.slf4j.LoggerFactory +import java.io.File +import java.security.MessageDigest + +/** + * Owns the per-project Quick Build scratch trees, `//{work,out}`. + * + * Pipeline intermediates live here on app-private storage rather than under + * `/.androidide/quickbuild/`, which sits on FUSE-backed `/storage/emulated` and costs + * ~50x per file (ADFA-4930); user sources never move. A tree exists only while its session + * does, and nothing in it needs to survive one. + * + * @property root parent of every per-project tree, created on demand; must be on app-private + * storage, since `/storage/emulated` gives up the whole point of this class. + * @property minFreeBytes free-space floor in bytes that [freeSpaceShortfall] enforces on + * [root]'s volume, injectable so tests can drive the shortfall path. + */ +class QuickBuildScratch( + private val root: File, + private val minFreeBytes: Long = DEFAULT_MIN_FREE_BYTES, +) { + /** Outcome of [prepare]: a usable tree, or a user-facing reason there is none. */ + sealed interface Preparation { + /** + * The project has a usable scratch tree. + * + * @property dir the tree itself; its `work/` and `out/` subdirs are created by the + * pipeline steps that need them, not by [prepare]. + */ + data class Ready( + val dir: File, + ) : Preparation + + /** + * There is no usable tree, and the build must not start. + * + * @property message the reason, already phrased for the user - provisioning surfaces + * it verbatim rather than mapping it to another string. + */ + data class Failed( + val message: QuickBuildMessage, + ) : Preparation + } + + /** + * Derives a project's stable directory key: `-`. + * + * The basename is only for human debuggability; uniqueness comes from the hash of the + * normalized absolute path, so `a/MyApp` and `b/MyApp` cannot collide and a project maps + * to the same tree across sessions. + * + * @param projectRoot the project's root directory; only its path is read, so a moved or + * renamed project keys to a different tree by design. + * @return a filesystem-safe single path segment - every character outside + * `[A-Za-z0-9._-]` is replaced, and the basename is truncated before the hash is joined. + */ + fun projectKey(projectRoot: File): String { + val normalized = projectRoot.absoluteFile.normalize().path + val digest = MessageDigest.getInstance("SHA-256").digest(normalized.toByteArray(Charsets.UTF_8)) + val hash = digest.joinToString("") { "%02x".format(it) }.take(HASH_CHARS) + val base = + projectRoot.name + .map { if (it.isLetterOrDigit() || it == '.' || it == '_' || it == '-') it else '_' } + .joinToString("") + .take(MAX_BASENAME_CHARS) + .ifEmpty { "project" } + return "$base-$hash" + } + + /** + * The project's scratch tree; parent of its `work/` and `out/` dirs. + * + * @param projectRoot the project's root directory. + * @return the tree's path, computed not created - only [prepare] creates it. + */ + fun treeFor(projectRoot: File): File = File(root, projectKey(projectRoot)) + + /** + * The project's executor payload-staging dir. + * + * @param projectRoot the project's root directory. + * @return the `work/` path; the executor creates it when it first stages a payload. + */ + fun workDirFor(projectRoot: File): File = File(treeFor(projectRoot), "work") + + /** + * The project's daemon output dir. + * + * @param projectRoot the project's root directory. + * @return the `out/` path, passed to the daemon as its `outDir`; the daemon creates it. + */ + fun outDirFor(projectRoot: File): File = File(treeFor(projectRoot), "out") + + /** + * Checks the private volume for room, so a full volume fails in seconds rather than as + * ENOSPC minutes into the proxy app build. A fixed floor ([minFreeBytes], default 100 MB) + * rather than an estimate from project size: sizing the project means walking its sources + * on FUSE, and intermediates do not track source size linearly. + * + * @return null when there is room, else the user-facing message to surface; creates [root] + * as a side effect, since usable space cannot be read through a directory that is not there. + */ + fun freeSpaceShortfall(): QuickBuildMessage? { + root.mkdirs() + val usable = root.usableSpace + if (usable >= minFreeBytes) return null + return QuickBuildMessage.NotEnoughStorage( + requiredMb = minFreeBytes / MB, + availableMb = usable / MB, + ) + } + + /** + * Creates the project's tree (the pipeline creates its own subdirs) and re-runs + * the space guard. Never throws - a failure comes back as [Preparation.Failed] + * with the message provisioning surfaces to the user. + * + * @param projectRoot the project's root directory. + * @return [Preparation.Ready] with the tree, or [Preparation.Failed] on a space shortfall or + * an unwritable location; an already-existing tree is reused, not cleared. + */ + fun prepare(projectRoot: File): Preparation { + freeSpaceShortfall()?.let { return Preparation.Failed(it) } + val tree = treeFor(projectRoot) + if (!tree.isDirectory && !tree.mkdirs()) { + return Preparation.Failed(QuickBuildMessage.ScratchDirUnavailable(tree.absolutePath)) + } + return Preparation.Ready(tree) + } + + /** + * Deletes the project's tree; a missing tree is a no-op. Session-teardown hook. + * + * Never throws - teardown has to finish. A tree that will not delete is logged at error, + * because the next session for that project reuses whatever is left. + * + * @param projectRoot the project whose tree to delete; its own directory, and the generation + * counter inside it, are untouched. + */ + fun remove(projectRoot: File) { + val tree = treeFor(projectRoot) + // deleteRecursively() also returns false for a tree that was never there, which is a + // documented no-op - so the residue, not the return value alone, is the failure. + if (!tree.deleteRecursively() && tree.exists()) { + log.error( + "Quick Build: could not fully delete the scratch tree {}; the next session for " + + "this project reuses what is left, so its build may start from stale intermediates", + tree.absolutePath, + ) + } + } + + /** + * Reclaims every tree under [root]. Called only at session-manager start, when nothing is + * live, so it clears leftovers from dead sessions and from projects deleted since. Only + * directories are touched; a stray file is not a tree and is left for whoever wrote it. + * + * A running session's tree is [remove]d at its own teardown, which is why this needs no + * spare-list: there is nothing live for it to protect. + */ + fun sweep() { + root.listFiles()?.forEach { child -> + if (child.isDirectory && !child.deleteRecursively() && child.exists()) { + // Not fatal: nothing live depends on a leftover, and the project it belongs + // to gets the same reuse behaviour as any warm tree. Logged because a tree + // that never clears is disk this class promises to reclaim. + log.warn( + "Quick Build: could not reclaim the leftover scratch tree {}; it stays on disk", + child.absolutePath, + ) + } + } + } + + companion object { + private val log = LoggerFactory.getLogger("QB-Scratch") + + /** See [freeSpaceShortfall] for why a fixed floor, and why this value. */ + const val DEFAULT_MIN_FREE_BYTES: Long = 100L * 1024 * 1024 + + private const val MB = 1024L * 1024 + private const val HASH_CHARS = 16 + private const val MAX_BASENAME_CHARS = 40 + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt new file mode 100644 index 0000000000..71877f7398 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt @@ -0,0 +1,398 @@ +package org.appdevforall.cotg.quickbuild.service.provision + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.withTimeoutOrNull +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage +import org.slf4j.LoggerFactory +import java.io.File +import java.security.MessageDigest + +/** + * What the installer needs to know about installed packages; implemented over + * PackageManager in the app module, faked in tests. + */ +interface InstalledPackages { + /** + * The package's uid, or null when not installed. + * + * @param packageName the applicationId to look up + * @return the uid, or null when the package is absent; PackageManager can lag an + * install by a moment, so a null right after one is not proof of failure + */ + fun uid(packageName: String): Int? + + /** + * PackageInfo.lastUpdateTime, or null when not installed. + * + * @param packageName the applicationId to look up + * @return the stamp, meaningful only as something to compare against an earlier read + */ + fun lastUpdateTime(packageName: String): Long? + + /** + * The installed base APK (sourceDir), or null when not installed. + * + * @param packageName the applicationId to look up + * @return the on-device APK, readable for hashing but never writable + */ + fun apkFile(packageName: String): File? + + /** + * Lowercase hex SHA-256 of the package's current signing certificate, or null when + * not installed or unreadable. Null means "cannot verify", and the provisioner then + * refuses to clobber the occupant rather than guess. + * + * @param packageName the applicationId to look up + * @return the lowercase hex digest, or null meaning "cannot verify" - never treat null + * as "no signature" or as a mismatch + */ + fun signingCertSha256(packageName: String): String? + + /** + * The installed package's `android:appComponentFactory` (API 28+), or null when not + * installed or none is declared. A Quick Build proxy app carries the runtime factory + * here, which is how it is told apart from the user's Standard-Run build under the + * same applicationId. + * + * @param packageName the applicationId to look up + * @return the declared factory's FQN, or null when absent, undeclared, or below API 28 + */ + fun appComponentFactory(packageName: String): String? +} + +/** + * One PackageInstaller status broadcast, decoupled from android.* so the wait logic is + * JVM-testable. The app module maps InstallationResultReceiver's intent extras into this. + * + * @property packageName null when the broadcast carried no EXTRA_PACKAGE_NAME, which + * failure broadcasts often do not; a waiter must then accept it as its own + * @property status the mapped status; anything unrecognized arrives as [Status.OTHER] + * @property message the OS failure text when there is one, shown to the user verbatim + */ +data class InstallBroadcast( + val packageName: String?, + val status: Status, + val message: String? = null, +) { + /** ABORTED is STATUS_FAILURE_ABORTED: the user cancelled the confirm dialog. */ + enum class Status { SUCCESS, FAILURE, ABORTED, PENDING_USER_ACTION, OTHER } + + /** True when no further broadcast will follow for this install. */ + val isTerminal: Boolean + get() = status == Status.SUCCESS || status == Status.FAILURE || status == Status.ABORTED +} + +/** What became of a [ProxyAppInstaller.ensureInstalled]. */ +sealed interface InstallOutcome { + /** + * The package is installed and current. + * + * @property uid the installed package's uid, which becomes the deploy channel's gate + */ + data class Installed( + val uid: Int, + ) : InstallOutcome + + /** + * The install could not be completed, and retrying will not help until something + * changes. Distinct from [ConfirmationNotGiven], which is merely unanswered. + * + * @property message the OS failure text, or a fallback when the broadcast carried none + */ + data class Failed( + val message: QuickBuildMessage, + ) : InstallOutcome + + /** + * The install started but the OS confirmation was never given. + * + * Distinct from [Failed] because nothing is broken: the APK is fine and retrying re-prompts, + * so callers can offer a retry instead of failing hard. DIALOG_NOT_SHOWN is reported as soon + * as PENDING_USER_ACTION arrives with the host app backgrounded, not after a silent timeout: + * the lifecycle-bound dialog subscriber means nobody will ever tap. + * + * @property message the user-facing text for this particular [reason]; safe to show as-is + * @property reason which of the three ways the confirmation went missing, and the only + * thing that tells a deliberate refusal from nobody-was-ever-asked + */ + data class ConfirmationNotGiven( + val message: QuickBuildMessage, + val reason: Reason, + ) : InstallOutcome { + /** + * Why the confirmation never came. Only DECLINED is a deliberate user answer; the + * other two mean nobody was ever asked, or was asked and walked away. + */ + enum class Reason { DIALOG_NOT_SHOWN, DECLINED, TIMED_OUT } + } +} + +/** + * Installs the Quick Build proxy app and waits for a real verdict rather than polling for a uid. + * + * Skips the install when the installed APK's bytes already match, which keeps the reload loop + * free of reinstalls across rebaselines and CoGo restarts. Failures arrive as PackageInstaller + * broadcasts with real messages, and a lastUpdateTime change backstops the MIUI intent + * fallback, which never broadcasts through our receiver. A broadcast with no package name is + * accepted as ours, erring toward a retryable failure rather than a false success. + */ +class ProxyAppInstaller( + /** Installed-package facts; every read goes through here so tests need no PackageManager. */ + private val packages: InstalledPackages, + /** Starts the install (ApkInstaller.installApk); false when it could not start. */ + private val launchInstall: suspend (File) -> Boolean, + /** InstallationResultReceiver broadcasts, adapted app-side. */ + private val broadcasts: Flow, + /** Whole-install budget, including the time the user spends tapping through dialogs. */ + private val timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, + /** + * How long one committed install may sit without any verdict before the prompt is + * re-issued. Must be well under [timeoutMillis], which still bounds the whole install. + */ + private val promptTimeoutMillis: Long = DEFAULT_PROMPT_TIMEOUT_MILLIS, + /** + * Whether the OS install-confirm dialog can be shown right now; the app wires this to + * a process-foreground probe. + * + * The dialog-owning subscriber is EventBus lifecycle-bound, so with the host app + * backgrounded a PENDING_USER_ACTION status never launches a dialog. The default of + * always-true keeps the plain wait-for-the-user behavior for callers without a probe. + */ + private val canShowConfirmDialog: () -> Boolean = { true }, +) { + /** + * Gets [packageName] installed from [apk], skipping the install when the bytes on + * device already match. + * + * @param apk the candidate APK; hashed against the installed one before anything runs + * @param packageName the applicationId the APK declares, used for every lookup and to + * match inbound broadcasts + * @return the verdict; never throws, and an unanswered confirmation comes back as + * [InstallOutcome.ConfirmationNotGiven] rather than a failure, so callers can retry + */ + suspend fun ensureInstalled( + apk: File, + packageName: String, + ): InstallOutcome { + val initialStamp = packages.lastUpdateTime(packageName) + val existingUid = packages.uid(packageName) + if (existingUid != null && isSameContent(apk, packageName)) { + log.info("{} already runs these bytes; skipping reinstall", packageName) + return InstallOutcome.Installed(existingUid) + } + + return coroutineScope { + // Subscribe before committing the install so a fast broadcast cannot slip + // past us. PENDING_USER_ACTION is decisive too when no confirm dialog can be + // launched, since nobody will ever tap. + val verdict = + async(start = CoroutineStart.UNDISPATCHED) { + broadcasts.first { broadcast -> + (broadcast.packageName == null || broadcast.packageName == packageName) && + ( + broadcast.isTerminal || + ( + broadcast.status == InstallBroadcast.Status.PENDING_USER_ACTION && + !canShowConfirmDialog() + ) + ) + } + } + val stampChanged = async { awaitStampChange(packageName, initialStamp) } + + val started = runCatching { launchInstall(apk) }.getOrDefault(false) + if (!started) { + verdict.cancel() + stampChanged.cancel() + return@coroutineScope InstallOutcome.Failed(QuickBuildMessage.InstallCouldNotStart) + } + + val awaitVerdict: suspend () -> InstallOutcome = { + select { + verdict.onAwait { broadcast -> classify(broadcast, packageName) } + stampChanged.onAwait { resolveUid(packageName) } + } + } + val outcome = + withTimeoutOrNull(timeoutMillis) { + // A commit whose confirm dialog never reached the user is indistinguishable + // from one the user is still reading, so the first wait is bounded rather than + // the whole budget. Re-committing costs a second dialog at worst and is the + // only way back from a prompt nobody was shown - what a CoGo process death does + // to the next session's first install, the dialog-owning subscriber being + // lifecycle-bound. The deferreds are reused, so a late verdict still resolves. + withTimeoutOrNull(promptTimeoutMillis) { awaitVerdict() } + ?: run { + if (canShowConfirmDialog()) { + log.info( + "no install verdict for {} in {}ms; re-issuing the prompt", + packageName, + promptTimeoutMillis, + ) + runCatching { launchInstall(apk) } + } + awaitVerdict() + } + } + verdict.cancel() + stampChanged.cancel() + outcome ?: confirmationNotGivenAtTimeout() + } + } + + /** + * Turns the broadcast that settled an install into its outcome. + * + * @param broadcast the terminal broadcast, or a PENDING_USER_ACTION no dialog can answer + * @param packageName the applicationId being installed, needed to read back the uid + * @return the outcome this broadcast means + */ + private suspend fun classify( + broadcast: InstallBroadcast, + packageName: String, + ): InstallOutcome = + when (broadcast.status) { + InstallBroadcast.Status.SUCCESS -> { + resolveUid(packageName) + } + + InstallBroadcast.Status.PENDING_USER_ACTION -> { + // The OS asked for a confirmation no dialog can deliver right now, so park + // immediately instead of waiting out the timeout. + InstallOutcome.ConfirmationNotGiven( + QuickBuildMessage.ReinstallReturnToCoGo, + InstallOutcome.ConfirmationNotGiven.Reason.DIALOG_NOT_SHOWN, + ) + } + + InstallBroadcast.Status.ABORTED -> { + InstallOutcome.ConfirmationNotGiven( + QuickBuildMessage.ReinstallDeclined, + InstallOutcome.ConfirmationNotGiven.Reason.DECLINED, + ) + } + + else -> { + InstallOutcome.Failed( + broadcast.message + ?.let(QuickBuildMessage::Literal) + ?: QuickBuildMessage.InstallFailed, + ) + } + } + + /** + * Explains a timeout with no verdict at all. + * + * Backgrounded, Android is still deferring the PENDING_USER_ACTION status, so no + * dialog was ever launched. Foregrounded, the dialog was up the whole time and the + * user walked away. + * + * @return the parked outcome, whose reason and text depend on which of those two it + * was; both are retryable + */ + private fun confirmationNotGivenAtTimeout(): InstallOutcome.ConfirmationNotGiven = + if (!canShowConfirmDialog()) { + InstallOutcome.ConfirmationNotGiven( + QuickBuildMessage.ReinstallReturnToCoGo, + InstallOutcome.ConfirmationNotGiven.Reason.DIALOG_NOT_SHOWN, + ) + } else { + InstallOutcome.ConfirmationNotGiven( + QuickBuildMessage.ReinstallTimedOut(timeoutMillis / 1000), + InstallOutcome.ConfirmationNotGiven.Reason.TIMED_OUT, + ) + } + + /** + * Polls until the package's lastUpdateTime moves off [initialStamp]. + * + * @param packageName the applicationId to watch + * @param initialStamp the stamp read before the install started; null means the package + * was absent, so any stamp at all counts as the change + */ + private suspend fun awaitStampChange( + packageName: String, + initialStamp: Long?, + ) { + while (true) { + val stamp = packages.lastUpdateTime(packageName) + if (stamp != null && stamp != initialStamp) return + delay(DEFAULT_POLL_MILLIS) + } + } + + /** + * Reads the uid of a just-installed package, tolerating PackageManager lag. + * + * @param packageName the applicationId just installed + * @return an installed outcome, or a failure once the bounded retries are spent + */ + private suspend fun resolveUid(packageName: String): InstallOutcome { + // The uid should exist the moment the install lands; retry briefly for the + // window between the success broadcast and PackageManager visibility. + repeat(UID_RETRIES) { + packages.uid(packageName)?.let { return InstallOutcome.Installed(it) } + delay(DEFAULT_POLL_MILLIS) + } + return InstallOutcome.Failed(QuickBuildMessage.InstalledButUnresolvable(packageName)) + } + + /** + * True when the installed APK's bytes match [apk]; an unreadable file reads as false. + * + * @param apk the freshly built proxy app APK, whose digest decides whether the install can + * be skipped entirely + * @param packageName the applicationId whose installed APK is compared against it + * @return true only on a confirmed match, so an unreadable file errs toward reinstalling + */ + private fun isSameContent( + apk: File, + packageName: String, + ): Boolean { + val installed = packages.apkFile(packageName) ?: return false + val candidate = sha256OrNull(apk) ?: return false + return candidate == sha256OrNull(installed) + } + + companion object { + private val log = LoggerFactory.getLogger("QB-ProxyInstaller") + + /** Long, because the user has to tap through PackageInstaller and Play Protect. */ + const val DEFAULT_TIMEOUT_MILLIS = 180_000L + + /** + * Long enough that a user reading the dialog is never re-prompted under it, short + * enough that a dialog that never appeared does not burn the whole budget in silence. + */ + const val DEFAULT_PROMPT_TIMEOUT_MILLIS = 45_000L + const val DEFAULT_POLL_MILLIS = 1_000L + private const val UID_RETRIES = 5 + + /** + * Streaming SHA-256 of a file; null on any IO problem, read as a content mismatch. + * + * @param file the file to hash; streamed, so APK-sized inputs cost no extra memory + * @return the lowercase hex digest, or null on any IO failure + */ + fun sha256OrNull(file: File): String? = + runCatching { + val md = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { + val read = input.read(buffer) + if (read < 0) break + md.update(buffer, 0, read) + } + } + md.digest().joinToString("") { "%02x".format(it) } + }.getOrNull() + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt new file mode 100644 index 0000000000..69383c7ae9 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt @@ -0,0 +1,42 @@ +package org.appdevforall.cotg.quickbuild.service.provision + +import org.appdevforall.cotg.quickbuild.domain.reload.RealIdInstall + +/** + * Decides whether tapping Quick Build or Standard Run should ask the user to confirm a + * clobber first. + * + * Both build types install under the project's real applicationId, so switching between them + * overwrites the installed app. The installed package's component factory says which build + * occupies the slot; [RealIdInstall] holds the rules. Stateless, so an install or uninstall + * outside CoGo cannot leave it stale. + * + * @property packages read on every call, never cached, which is what keeps this stateless + */ +class QuickBuildClobberCheck( + private val packages: InstalledPackages, +) { + /** + * True when a Quick Build tap for [realApplicationId] would clobber a different build. + * + * @param realApplicationId the project's own applicationId, not the proxy app's + * @return true only when the slot holds something a Quick Build would overwrite; an + * empty slot needs no confirmation + */ + fun quickBuildNeedsConfirm(realApplicationId: String): Boolean = + RealIdInstall.quickBuildNeedsClobberConfirm( + realAppInstalled = packages.uid(realApplicationId) != null, + installedFactory = packages.appComponentFactory(realApplicationId), + ) + + /** + * True when a Standard Run for [realApplicationId] would clobber a Quick Build proxy app. + * + * @param realApplicationId the project's own applicationId, the slot both builds share + * @return true only when the installed app carries the Quick Build runtime factory + */ + fun standardRunNeedsConfirm(realApplicationId: String): Boolean = + RealIdInstall.standardRunNeedsClobberConfirm( + packages.appComponentFactory(realApplicationId), + ) +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildProvisioner.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildProvisioner.kt new file mode 100644 index 0000000000..b92be02a96 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildProvisioner.kt @@ -0,0 +1,150 @@ +package org.appdevforall.cotg.quickbuild.service.provision + +import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo +import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage + +/** + * The session manager's door to the real Gradle world: the one-time proxy app build and + * the full-Gradle rebuild fallback. + * + * Implemented in the app module over GradleBuildService and ApkInstaller. The interface + * keeps `:quick-build` off CoGo's project-model modules and the session manager testable. + */ +interface QuickBuildProvisioner { + /** + * Builds, installs, and resolves the uid of the proxy app for the first time. + * + * Must not throw: failures come back as [ProvisionOutcome.Failure] and surface in the + * UI. + * + * @return the baseline, its uid, and the layout on success; a message on failure + */ + suspend fun provision(): ProvisionOutcome + + /** + * Rebuilds and reinstalls the proxy app after an invalidation, moving the session to + * the new baseline. The orchestrator's rebuild protocol brackets this call. + * + * @return the re-read baseline and layout on success; otherwise a failure, an + * unconfirmed install, or a busy Gradle slot, which callers must not conflate + */ + suspend fun rebuildProxyApp(): ProxyAppRebuildOutcome + + /** + * Builds the proxy app eagerly at project open, after the normal Gradle sync, while + * its daemon is still warm. + * + * Installs nothing: the install waits for the first Quick Build tap, whose [provision] + * re-runs the build cheaply against current disk. Failures are logged, never surfaced, + * since the user did not ask for this build. + */ + suspend fun prebuildProxyApp() {} + + /** + * Stops the proxy app build currently running through Gradle. + * + * Cancelling the coroutine that awaits [provision], [prebuildProxyApp], or + * [rebuildProxyApp] does not stop Gradle, which runs out of process behind a future, so + * a stop must reach the tooling server's cancellation token. Call only while the session + * owns the Gradle slot: there is one token, so issuing it blind could kill a Standard Run. + * + * @return true when a cancellation reached Gradle; false, the default, means this + * implementation cannot cancel and the caller must not claim it stopped anything + */ + fun cancelProxyAppBuild(): Boolean = false +} + +/** What became of a [QuickBuildProvisioner.provision]. */ +sealed interface ProvisionOutcome { + /** The proxy app is built, installed, and identified; the session can be assembled. */ + data class Success( + /** The report read from the setup.json this build generated. */ + val proxyApp: ProxyAppInfo, + /** PackageManager uid of the installed proxy app; the deploy-channel gate. */ + val proxyAppUid: Int, + /** Derived from the same setup.json as [proxyApp], never from an earlier one. */ + val layout: QuickBuildProjectLayout, + /** + * Build variant this proxy app was built from ("debug", "demoDebug"), or null when + * the provisioner does not track one. The session records it so a later variant + * switch reprovisions instead of hot-reloading into the old variant's application + * id. + */ + val variantName: String? = null, + /** + * The generation stamped into the installed APK's baseline, allocated from the + * project's persistent counter before the Gradle build ran; 0 for an unstamped + * build (a provisioner that does not stamp). The installed app boots at this + * number, so the session adopts it as the deployed generation. + */ + val baselineGeneration: Long = 0L, + ) : ProvisionOutcome + + /** + * Provisioning did not complete, for any reason from a Gradle failure to a declined + * install. + * + * @property message user-facing failure text; the session tears down and shows it + */ + data class Failure( + val message: QuickBuildMessage, + ) : ProvisionOutcome +} + +/** What became of a [QuickBuildProvisioner.rebuildProxyApp]. */ +sealed interface ProxyAppRebuildOutcome { + /** + * Carries the re-read proxy app report and the layout derived from it. + * + * A rebuild regenerates setup.json, so the live session must rebuild its + * ProxyAppInfo-derived state from this. Keeping the provisioning-time snapshot would + * leave the deploy policy blind to components the rebuild just added. + */ + data class Success( + /** The re-read report, which may declare components the old baseline did not. */ + val proxyApp: ProxyAppInfo, + /** Derived from the same re-read setup.json as [proxyApp]. */ + val layout: QuickBuildProjectLayout, + /** + * The generation stamped into the reinstalled APK's baseline; 0 for an unstamped + * build. See [ProvisionOutcome.Success.baselineGeneration]. + */ + val baselineGeneration: Long = 0L, + ) : ProxyAppRebuildOutcome + + /** + * The rebuild did not complete, so the session is still on the baseline that could not + * take the deploy. + * + * @property message user-facing failure text + */ + data class Failure( + val message: QuickBuildMessage, + ) : ProxyAppRebuildOutcome + + /** + * The Gradle build produced a good APK but the OS install confirmation was never + * given (see [InstallOutcome.ConfirmationNotGiven]). + * + * Distinct from [Failure] because nothing needs fixing: re-running the rebuild is + * cheap and simply re-prompts, so the session manager parks in a retryable state + * instead of tearing down. + * + * @property message user-facing text specific to how the confirmation went missing, so + * it should be shown alongside the retry rather than swapped for a generic prompt + */ + data class InstallNotConfirmed( + val message: QuickBuildMessage, + ) : ProxyAppRebuildOutcome + + /** + * The rebuild never started because the device's single Gradle slot was taken, by + * CoGo's own project sync or a Standard Run. + * + * Nothing was built, installed, or prompted, so this is not a failure to report and + * does not count against the bounded auto-retry budget. The session parks and a later + * trigger runs it. + */ + data object BuildSlotBusy : ProxyAppRebuildOutcome +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/README.md b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/README.md new file mode 100644 index 0000000000..98c33319c9 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/README.md @@ -0,0 +1,11 @@ +# `service/provision/` - getting a proxy app built, installed, and launched + +This folder holds the provisioning side of the service layer: building the Gradle proxy app (first provision and full-rebuild fallback), installing it under the project's real applicationId, launching it, and the clobber check that guards the shared install slot. The `QuickBuildProvisioner` / `ProxyAppLauncher` / `InstalledPackages` interfaces are implemented in the app module (they need Gradle, Context, and PackageManager); everything here stays JVM-testable and depends down on `data/` and `domain/`. + +| File | Purpose | +| --- | --- | +| [`QuickBuildProvisioner.kt`](QuickBuildProvisioner.kt) | Interface: the door to Gradle (provision, rebuild, prebuild, cancel), plus the `ProvisionOutcome` / `ProxyAppRebuildOutcome` result types. | +| [`ProxyAppBuildRunner.kt`](ProxyAppBuildRunner.kt) | Runs a provision or rebuild as a stateless verdict - disk guard, build, scratch tree, deploy session, daemon start - returning a result the manager dispatches on. | +| [`ProxyAppInstaller.kt`](ProxyAppInstaller.kt) | Installs the proxy app via CoGo's install pathway, skips when APK bytes already match, and waits on PackageInstaller broadcasts for a real verdict. | +| [`ProxyAppLauncher.kt`](ProxyAppLauncher.kt) | Interface: relaunches the proxy app so a fresh process boots on the newest persisted generation. | +| [`QuickBuildClobberCheck.kt`](QuickBuildClobberCheck.kt) | Stateless check of whether a Quick Build or Standard Run tap would clobber the other build in the shared install slot, keyed on the installed component factory. | diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt new file mode 100644 index 0000000000..12126e6676 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt @@ -0,0 +1,229 @@ +package org.appdevforall.cotg.quickbuild.service.session + +import android.content.ComponentCallbacks2 +import org.appdevforall.cotg.quickbuild.data.DaemonConfig +import org.appdevforall.cotg.quickbuild.data.DaemonReply +import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo +import org.appdevforall.cotg.quickbuild.data.QuickBuildDaemon +import org.appdevforall.cotg.quickbuild.data.QuickBuildPaths +import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout +import org.appdevforall.cotg.quickbuild.data.QuickBuildScratch +import org.slf4j.LoggerFactory + +/** + * Owns the compile daemon's lifecycle: the epoch rule, respawn supersession, low-memory shrink. + * + * The epoch counts intentional daemon transitions - every start or shutdown the session manager + * initiates outside the respawn path. [start] and [shutdown] deliberately do not bump it: the + * teardown must bump synchronously before it suspends, and [respawn]'s cleanup rule counts + * exactly one transition, which an auto-bump would break. Call only on the session dispatcher. + */ +internal class QuickBuildDaemonController( + /** The daemon itself; this class owns when it starts and stops, not what it does. */ + private val daemon: QuickBuildDaemon, + /** App-private scratch trees; the daemon's output dir lives here. */ + private val scratch: QuickBuildScratch, + /** Locations of the bundled aapt2, d8, android.jar, and Compose compiler plugin. */ + private val paths: QuickBuildPaths, +) { + /** + * Count of intentional daemon transitions, used to detect that a respawn was + * superseded while its start was in flight. Only touched on the session dispatcher. + * + * Exactly one transition since a respawn captured the epoch means the superseding shutdown + * itself, so a daemon the stale start brought up is a zombie the respawn must stop; more + * than one means a successor flow already started a fresh daemon to leave alone. + */ + private var daemonEpoch = 0L + + /** Set only on the session dispatcher; a build in flight defers the teardown here. */ + private var pendingLowMemoryTeardown = false + + /** + * Records an intentional daemon lifecycle transition. + * + * Non-suspending on purpose: the session teardown must bump before its shutdown + * suspends, so a concurrent respawn can never observe the pre-teardown epoch after + * the teardown began. + */ + fun markIntentionalTransition() { + daemonEpoch++ + } + + /** + * The current epoch, captured at effect time and passed back into [respawn]. + * + * @return an opaque counter, meaningful only when compared with a later read + */ + fun epochSnapshot(): Long = daemonEpoch + + /** + * Starts the daemon against [layout] + [proxyApp]'s config. Never bumps the epoch. + * + * @param layout supplies the project root and compile classpath + * @param proxyApp supplies the baseline facts the config needs, currently whether + * Compose is enabled + * @return the daemon's reply; callers must treat anything but Ok as "no daemon" + */ + suspend fun start( + layout: QuickBuildProjectLayout, + proxyApp: ProxyAppInfo, + ): DaemonReply = daemon.start(configFor(layout, proxyApp)) + + /** Stops the daemon. Never bumps the epoch - see [markIntentionalTransition]. */ + suspend fun shutdown() { + daemon.shutdown() + } + + /** What became of a [respawn]. The manager dispatches on it; this class does not. */ + sealed interface RespawnOutcome { + /** The daemon is up again; the manager re-seeds via the orchestrator. */ + data object Respawned : RespawnOutcome + + /** + * An intentional transition superseded the respawn, before or during its start. + * The successor flow owns the daemon lifecycle, and any zombie daemon the stale + * start brought up was already stopped. + */ + data object Superseded : RespawnOutcome + + /** + * The daemon could not be brought back. The session stays degraded rather than + * auto-retrying, which would just spin on a hard-broken daemon. + * + * @property message the daemon's own failure text, or a generic note + */ + data class Failed( + val message: String, + ) : RespawnOutcome + } + + /** + * Restarts a dead daemon unless an intentional transition superseded the attempt. + * + * @param layout the live session's layout, unchanged by the daemon's death + * @param proxyApp the live session's current baseline + * @param startEpoch the [epochSnapshot] taken when the respawn effect fired + * @return respawned, superseded, or failed; a superseded result has already stopped any + * zombie daemon this attempt brought up + */ + suspend fun respawn( + layout: QuickBuildProjectLayout, + proxyApp: ProxyAppInfo, + startEpoch: Long, + ): RespawnOutcome { + if (startEpoch != daemonEpoch) { + // An intentional daemon transition already superseded this respawn before it + // even started; the successor flow owns the daemon lifecycle. + log.info("Quick-build daemon respawn superseded before start; discarding") + return RespawnOutcome.Superseded + } + val started = daemon.start(configFor(layout, proxyApp)) + if (startEpoch != daemonEpoch) { + // An intentional shutdown landed while this respawn's start was in flight, so + // the superseding flow owns the daemon lifecycle now. See daemonEpoch for the + // exactly-one-transition cleanup rule. + if (started is DaemonReply.Ok && daemonEpoch == startEpoch + 1) { + log.info("Quick-build daemon respawn outlived an intentional shutdown; stopping its daemon") + daemon.shutdown() + } else { + log.info("Quick-build daemon respawn outlived a daemon restart; discarding") + } + return RespawnOutcome.Superseded + } + return when (started) { + is DaemonReply.Ok -> { + RespawnOutcome.Respawned + } + + else -> { + RespawnOutcome.Failed( + (started as? DaemonReply.Failed)?.message ?: "unknown failure", + ) + } + } + } + + /** + * Tears the daemon down, but only when the system is genuinely short of memory: + * [ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL] and the cached-process levels + * above it. + * + * `RUNNING_MODERATE` and `RUNNING_LOW` are transient, and `UI_HIDDEN` only means CoGo + * went to the background, which is the middle of the loop - so all three are excluded. + * + * @param level the raw `ComponentCallbacks2` level the host forwarded + * @param buildInFlight true to defer the teardown rather than interrupt a build; + * [shrinkIfPending] then carries it out once the build lands + */ + suspend fun onTrimMemory( + level: Int, + buildInFlight: Boolean, + ) { + if (level < ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) { + log.debug("Quick Build: onTrimMemory({}) below the shrink threshold; no-op", level) + return + } + if (level == ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) { + // Not memory pressure: the user just switched away, typically to their own + // proxy app mid-loop. Keep the daemon warm. + log.debug("Quick Build: onTrimMemory(UI_HIDDEN); keeping the daemon warm") + return + } + pendingLowMemoryTeardown = true + shrinkIfPending(buildInFlight) + } + + /** + * Carries out a deferred low-memory teardown once no build is in flight. + * + * A build in flight leaves the pending flag set for the manager's state collector to + * retry. Idempotent: with no pending request, or a daemon already down, this is a + * silent no-op. + * + * @param buildInFlight true to leave the request pending for a later call + */ + suspend fun shrinkIfPending(buildInFlight: Boolean) { + if (buildInFlight) return + if (!pendingLowMemoryTeardown) return + pendingLowMemoryTeardown = false + if (!daemon.isRunning) return + log.info("Quick Build: tearing down the compile daemon for low memory; the next build re-warms it") + markIntentionalTransition() + daemon.shutdown() + } + + /** + * Builds the daemon config for one project layout and proxy app baseline. + * + * @param layout supplies the project root and the compile classpath + * @param proxyApp supplies whether the Compose compiler plugin must be loaded, and the API + * level the seed payload was dexed at + * @return the config; its output dir is deliberately app-private scratch, never a path + * under the FUSE-backed project root + */ + private fun configFor( + layout: QuickBuildProjectLayout, + proxyApp: ProxyAppInfo, + ): DaemonConfig = + DaemonConfig( + projectRoot = layout.projectRoot, + classpath = layout.compileClasspath(), + // App-private scratch: the daemon's output tree writes many small files and + // is the biggest cost on FUSE. The daemon's scratchFsType reply reports + // whichever filesystem this dir lands on. + outDir = scratch.outDirFor(layout.projectRoot), + aapt2 = paths.aapt2, + d8Jar = paths.d8Jar, + androidJar = paths.androidJar, + compilerPlugins = + if (proxyApp.composeEnabled) listOf(paths.composeCompilerPlugin) else emptyList(), + // Not the protocol default: the daemon's increments patch a baseline the proxy app + // build already dexed, so they have to be dexed at that build's API level. + minApi = proxyApp.minApi, + ) + + private companion object { + private val log = LoggerFactory.getLogger("QB-DaemonController") + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt new file mode 100644 index 0000000000..42eb9e651b --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt @@ -0,0 +1,1172 @@ +package org.appdevforall.cotg.quickbuild.data + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic +import org.appdevforall.cotg.quickbuild.protocol.ConfigureRequest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Edge and failure paths of [DaemonProcessClient] against scripted fake daemons, in the + * style of [DaemonProcessClientTest]: a shell script stands in for the java binary and + * plays back canned protocol lines (optionally capturing what the client wrote, so + * tests can assert the wire contract). + */ +class DaemonProcessClientEdgeTest { + @TempDir + lateinit var tmp: File + + private class ScriptedPaths( + base: File, + override val javaBinary: File, + ) : QuickBuildPaths { + override val daemonJar = File(base, "daemon/quickbuild-daemon.jar") + override val runtimeAar = File(base, "quickbuild-runtime.aar") + override val aapt2 = File(base, "aapt2") + override val d8Jar = File(base, "d8.jar") + override val composeCompilerPlugin = File(base, "compose-compiler-plugin.jar") + override val androidJar = File(base, "android.jar") + override val projectScratchRoot = File(base, "app-private/quickbuild-scratch") + + override fun daemonEnvironment(): Map = mapOf("PATH" to "/usr/bin:/bin") + } + + /** Writes a fake-java script with [body] as its full shell text and returns paths using it. */ + private fun scriptedPaths(body: String): ScriptedPaths { + val script = File(tmp, "fake-java.sh") + script.writeText("#!/bin/sh\n$body\n") + script.setExecutable(true) + File(tmp, "daemon").mkdirs() + return ScriptedPaths(tmp, script) + } + + /** + * @param pid a pid the fake daemon wrote for itself. + * @return true while that pid is still a live process. Uses the shell's own kill builtin so + * it needs no /bin/kill and no java.lang.ProcessHandle (absent from the Android API). + */ + private fun isProcessAlive(pid: String): Boolean = ProcessBuilder("/bin/sh", "-c", "kill -0 $pid 2>/dev/null").start().waitFor() == 0 + + /** + * Shell prelude defining `reply`, which answers one request line with an ok response + * carrying that request's own id - so a script can serve any number of requests without + * knowing where the client's id counter has got to. + */ + private val replyOk = + """ + reply() { + id=${'$'}(printf '%s' "${'$'}1" | sed 's/.*"id":\([0-9]*\).*/\1/') + printf '{"id":%s,"ok":true,"protocolVersion":%s}\n' \ + "${'$'}id" '${DaemonProcessClient.EXPECTED_PROTOCOL_VERSION}' + } + """.trimIndent() + + /** @return paths to a fake-java script that runs [body] with `reply` already defined. */ + private fun replyingPaths(body: String): ScriptedPaths = scriptedPaths("$replyOk\n$body") + + /** @return the client's per-spawn deliberate-stop marker, which has no public surface. */ + private fun DaemonProcessClient.stopMarker(): AtomicBoolean { + val field = DaemonProcessClient::class.java.getDeclaredField("deliberateStop") + field.isAccessible = true + return field.get(this) as AtomicBoolean + } + + private fun okConfigure(extra: String = "") = + """{"id":1,"ok":true,"protocolVersion":${DaemonProcessClient.EXPECTED_PROTOCOL_VERSION}$extra}""" + + private fun config( + compilerPlugins: List = emptyList(), + minApi: Int = ConfigureRequest.DEFAULT_MIN_API, + ): DaemonConfig = + DaemonConfig( + projectRoot = tmp, + classpath = emptyList(), + outDir = File(tmp, "out"), + aapt2 = File(tmp, "aapt2"), + d8Jar = File(tmp, "d8.jar"), + androidJar = File(tmp, "android.jar"), + compilerPlugins = compilerPlugins, + minApi = minApi, + ) + + private fun withClient( + paths: QuickBuildPaths, + timeoutMillis: Long = 10_000, + block: suspend (DaemonProcessClient) -> T, + ): T { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val client = DaemonProcessClient(paths, scope, requestTimeoutMillis = timeoutMillis) + return try { + runBlocking { block(client) } + } finally { + runBlocking { client.shutdown() } + scope.cancel() + } + } + + @Test + fun `a java binary that cannot spawn fails with daemonDied`() { + val paths = ScriptedPaths(tmp, File(tmp, "no-such-java")) + File(tmp, "daemon").mkdirs() + + val reply = withClient(paths) { it.start(config()) } + + assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + val failed = reply as DaemonReply.Failed + assertThat(failed.message).contains("Failed to spawn daemon") + assertThat(failed.daemonDied).isTrue() + } + + @Test + fun `a daemon that rejects configure fails without claiming death`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '{"id":1,"ok":false,"diagnostics":[]}' + read line + printf '%s\n' '{"id":2,"ok":true}' + """.trimIndent(), + ) + + val reply = withClient(paths) { it.start(config()) } + + assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + val failed = reply as DaemonReply.Failed + assertThat(failed.message).contains("Daemon rejected configuration") + assertThat(failed.daemonDied).isFalse() + } + + @Test + fun `a non-integer protocol version reads as no protocolVersion`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '{"id":1,"ok":true,"protocolVersion":"vintage"}' + read line + printf '%s\n' '{"id":2,"ok":true}' + """.trimIndent(), + ) + + val reply = withClient(paths) { it.start(config()) } + + assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + assertThat((reply as DaemonReply.Failed).message).contains("no protocolVersion") + } + + @Test + fun `a non-primitive protocol version reads as no protocolVersion`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '{"id":1,"ok":true,"protocolVersion":{"v":3}}' + read line + printf '%s\n' '{"id":2,"ok":true}' + """.trimIndent(), + ) + + val reply = withClient(paths) { it.start(config()) } + + assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + assertThat((reply as DaemonReply.Failed).message).contains("no protocolVersion") + } + + @Test + fun `a non-primitive scratchFsType stays null instead of crashing configure`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure(""","scratchFsType":["fuse"]""")}' + read line + printf '%s\n' '{"id":2,"ok":true}' + """.trimIndent(), + ) + + withClient(paths) { client -> + assertThat(client.start(config())).isEqualTo(DaemonReply.Ok(Unit)) + assertThat(client.scratchFsType).isNull() + } + } + + @Test + fun `isRunning tracks configure and shutdown`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":true}' + """.trimIndent(), + ) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val client = DaemonProcessClient(paths, scope) + try { + assertThat(client.isRunning).isFalse() + runBlocking { client.start(config()) } + assertThat(client.isRunning).isTrue() + runBlocking { client.shutdown() } + assertThat(client.isRunning).isFalse() + } finally { + runBlocking { client.shutdown() } + scope.cancel() + } + } + + @Test + fun `a request before start fails as not running`() { + val paths = scriptedPaths("read line") + + val reply = withClient(paths) { it.ping() } + + // ping maps the Failed reply to false - and the client must not have spawned. + assertThat(reply).isFalse() + } + + @Test + fun `a request after shutdown fails as not running with daemonDied`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":true}' + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.shutdown() + client.compile(emptyList(), emptyList()) + } + + assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + val failed = reply as DaemonReply.Failed + assertThat(failed.message).contains("Daemon is not running") + assertThat(failed.daemonDied).isTrue() + } + + @Test + fun `an unanswered request times out naming the op with the daemon still alive`() { + // Configure is answered; the compile request is swallowed while the script sleeps. + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + sleep 30 + """.trimIndent(), + ) + + val reply = + withClient(paths, timeoutMillis = 300) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.compile(emptyList(), emptyList()) + } + + assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + val failed = reply as DaemonReply.Failed + assertThat(failed.message).contains("did not answer 'compile'") + assertThat(failed.daemonDied).isFalse() + } + + @Test + fun `a daemon that dies mid-request fails the pending request as dead`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + exit 3 + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.compile(emptyList(), emptyList()) + } + + assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + val failed = reply as DaemonReply.Failed + assertThat(failed.message).contains("did not answer 'compile'") + assertThat(failed.daemonDied).isTrue() + } + + @Test + fun `an unexpected daemon exit fires the death listener with the exit code`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + exit 7 + """.trimIndent(), + ) + val latch = CountDownLatch(1) + var reportedCode = Int.MIN_VALUE + + withClient(paths) { client -> + client.setDeathListener { code -> + reportedCode = code + latch.countDown() + } + check(client.start(config()) is DaemonReply.Ok) + assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue() + } + + assertThat(reportedCode).isEqualTo(7) + } + + @Test + fun `a requested shutdown does not fire the death listener`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":true}' + """.trimIndent(), + ) + var died = false + // Own scope rather than withClient's, so the test can join the client's coroutines + // before they are cancelled. + val supervisor = SupervisorJob() + val scope = CoroutineScope(supervisor + Dispatchers.IO) + val client = DaemonProcessClient(paths, scope) + + try { + runBlocking { + client.setDeathListener { died = true } + check(client.start(config()) is DaemonReply.Ok) + client.shutdown() + // The death watcher is a child of this scope and ends only after proc.waitFor() + // returned and it decided whether to fire, so joining it is the real signal a + // fixed sleep was standing in for: a listener firing late cannot escape the + // join, because the coroutine that would call it has completed. + withTimeout(30_000) { supervisor.children.toList().forEach { it.join() } } + } + assertThat(died).isFalse() + } finally { + runBlocking { client.shutdown() } + scope.cancel() + } + } + + @Test + fun `noise on stdout and stderr does not derail response matching`() { + // Garbage line, a JSON line without id, an unknown-id response - then the real reply. + val paths = + scriptedPaths( + """ + read line + echo 'not json at all' + printf '%s\n' '{"progress":"still warming"}' + printf '%s\n' '{"id":999,"ok":true}' + echo 'daemon stderr chatter' >&2 + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":true}' + """.trimIndent(), + ) + + val reply = withClient(paths) { it.start(config()) } + + assertThat(reply).isEqualTo(DaemonReply.Ok(Unit)) + } + + @Test + fun `a response without ok true is a build failure with parsed diagnostics`() { + val diagnostics = + """[ + {"severity":"warning","message":"shadowed","file":"A.kt","line":3,"column":9}, + {"severity":"ERROR","message":"broken"}, + {"message":"defaults to error"}, + {"severity":"ERROR"}, + "not an object", + {"severity":"ERROR","message":"odd shapes","file":{"x":1},"line":"3","column":[1]} + ]""".replace(Regex("\\s+"), "") + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":false,"diagnostics":$diagnostics}' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.compile(emptyList(), emptyList()) + } + + val failure = reply as DaemonReply.BuildFailed + assertThat(failure.diagnostics).hasSize(5) + val (warning, error, defaulted, noMessage, oddShapes) = failure.diagnostics + assertThat(warning.severity).isEqualTo(BuildDiagnostic.Severity.WARNING) + assertThat(warning.file).isEqualTo("A.kt") + assertThat(warning.line).isEqualTo(3) + assertThat(warning.column).isEqualTo(9) + assertThat(error.severity).isEqualTo(BuildDiagnostic.Severity.ERROR) + assertThat(defaulted.severity).isEqualTo(BuildDiagnostic.Severity.ERROR) + assertThat(noMessage.message).isEqualTo("unknown error") + assertThat(oddShapes.file).isNull() + // "3" is a JSON primitive; gson coerces it - the guard is about non-primitives. + assertThat(oddShapes.line).isEqualTo(3) + assertThat(oddShapes.column).isNull() + } + + @Test + fun `a build failure without a diagnostics array reports none`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":false}' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.dex(emptyList()) + } + + assertThat(reply).isInstanceOf(DaemonReply.BuildFailed::class.java) + assertThat((reply as DaemonReply.BuildFailed).diagnostics).isEmpty() + } + + @Test + fun `ping is false when the daemon answers not-ok`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":false}' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent(), + ) + + val alive = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.ping() + } + + assertThat(alive).isFalse() + } + + @Test + fun `compile reply without classesDir fails naming the key instead of guessing a path`() { + // The conventional guess would have been /classes - the daemon's real classes + // tree, still holding the PREVIOUS build's output. Deploying that reports success with + // the user's edit missing, so an absent key has to fail. + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":true}' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.compile(emptyList(), emptyList()) + } + + assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + assertThat((reply as DaemonReply.Failed).message).contains("missing 'classesDir'") + } + + @Test + fun `a non-primitive classesDir fails naming the key`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":true,"classesDir":["/out/classes"]}' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.compile(emptyList(), emptyList()) + } + + assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + assertThat((reply as DaemonReply.Failed).message).contains("missing 'classesDir'") + } + + @Test + fun `compile reply keeps only primitive classesChanged entries`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":true,"classesDir":"/out/classes","classesChanged":["com/a/A",{"weird":1},"com/a/B"]}' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.compile(emptyList(), emptyList()) + } + + val output = (reply as DaemonReply.Ok).value + assertThat(output.classesDir).isEqualTo(File("/out/classes")) + assertThat(output.changedClassFiles).containsExactly("com/a/A", "com/a/B").inOrder() + } + + @Test + fun `a non-numeric timing field reads as not measured`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":true,"classesDir":"/out/classes","kotlinMillis":"fast","javaMillis":[1]}' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.compile(emptyList(), emptyList()) + } + + val output = (reply as DaemonReply.Ok).value + assertThat(output.kotlinMillis).isNull() + assertThat(output.javaMillis).isNull() + } + + @Test + fun `dex reply without dexFile fails naming the key instead of guessing a path`() { + // Guessing /classes.dex is not even where the daemon writes (it writes + // /dex/classes.dex), so it would resolve nothing or an unrelated leftover. + // Either way the reply has to fail, not guess. + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":true}' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.dex(emptyList()) + } + + assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + assertThat((reply as DaemonReply.Failed).message).contains("missing 'dexFile'") + } + + @Test + fun `relink reply maps the resources apk and its timings`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":true,"resourcesArsc":"/out/res/linked-res.apk","aapt2CompileMillis":40,"aapt2LinkMillis":140}' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.relink( + RelinkInputs( + resDirs = listOf(File(tmp, "res")), + manifest = File(tmp, "AndroidManifest.xml"), + ), + ) + } + + val output = (reply as DaemonReply.Ok).value + assertThat(output.resourceApk).isEqualTo(File("/out/res/linked-res.apk")) + assertThat(output.aapt2CompileMillis).isEqualTo(40) + assertThat(output.aapt2LinkMillis).isEqualTo(140) + } + + @Test + fun `relink reply without a path fails naming the key instead of guessing a path`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":true}' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.relink( + RelinkInputs( + resDirs = listOf(File(tmp, "res")), + manifest = File(tmp, "AndroidManifest.xml"), + ), + ) + } + + assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + assertThat((reply as DaemonReply.Failed).message).contains("missing 'resourcesArsc'") + } + + @Test + fun `the wire carries optional fields only when present`() { + // The script captures every request line so the test can assert the JSON contract: + // omitted-when-empty fields stay off the wire, present ones make it on. + val paths = + scriptedPaths( + """ + read line + printf '%s' "${'$'}line" > '$tmp/configure-request.txt' + printf '%s\n' '${okConfigure()}' + read line + printf '%s' "${'$'}line" > '$tmp/compile-request.txt' + printf '%s\n' '{"id":2,"ok":true,"classesDir":"/out/classes"}' + read line + printf '%s' "${'$'}line" > '$tmp/relink-request.txt' + printf '%s\n' '{"id":3,"ok":true,"resourcesArsc":"/out/res/linked-res.apk"}' + read line + printf '%s\n' '{"id":4,"ok":true}' + """.trimIndent(), + ) + + withClient(paths) { client -> + val plugin = File(tmp, "compose-plugin.jar") + check(client.start(config(compilerPlugins = listOf(plugin))) is DaemonReply.Ok) + check( + client.compile( + allSources = listOf(File(tmp, "A.kt")), + changedFiles = listOf(File(tmp, "A.kt")), + removedFiles = listOf(File(tmp, "Gone.kt")), + ) is DaemonReply.Ok, + ) + check( + client.relink( + RelinkInputs( + resDirs = listOf(File(tmp, "res")), + manifest = File(tmp, "AndroidManifest.xml"), + stableIdsFile = File(tmp, "stableIds.txt"), + libraryResources = listOf(File(tmp, "lib.flat")), + ), + ) is DaemonReply.Ok, + ) + } + + val configureRequest = File(tmp, "configure-request.txt").readText() + assertThat(configureRequest).contains("compilerPlugins") + assertThat(configureRequest).contains("compose-plugin.jar") + val compileRequest = File(tmp, "compile-request.txt").readText() + assertThat(compileRequest).contains("removedFiles") + assertThat(compileRequest).contains("Gone.kt") + val relinkRequest = File(tmp, "relink-request.txt").readText() + assertThat(relinkRequest).contains("stableIds") + assertThat(relinkRequest).contains("libraryResources") + } + + @Test + fun `empty optional fields stay off the wire`() { + val paths = + scriptedPaths( + """ + read line + printf '%s' "${'$'}line" > '$tmp/configure-request.txt' + printf '%s\n' '${okConfigure()}' + read line + printf '%s' "${'$'}line" > '$tmp/compile-request.txt' + printf '%s\n' '{"id":2,"ok":true,"classesDir":"/out/classes"}' + read line + printf '%s' "${'$'}line" > '$tmp/relink-request.txt' + printf '%s\n' '{"id":3,"ok":true,"resourcesArsc":"/out/res/linked-res.apk"}' + read line + printf '%s\n' '{"id":4,"ok":true}' + """.trimIndent(), + ) + + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + check(client.compile(emptyList(), emptyList()) is DaemonReply.Ok) + check( + client.relink( + RelinkInputs( + resDirs = listOf(File(tmp, "res")), + manifest = File(tmp, "AndroidManifest.xml"), + ), + ) is DaemonReply.Ok, + ) + } + + assertThat(File(tmp, "configure-request.txt").readText()).doesNotContain("compilerPlugins") + assertThat(File(tmp, "compile-request.txt").readText()).doesNotContain("removedFiles") + val relinkRequest = File(tmp, "relink-request.txt").readText() + assertThat(relinkRequest).doesNotContain("stableIds") + assertThat(relinkRequest).doesNotContain("libraryResources") + } + + @Test + fun `a protocol-mismatch start shuts the child down instead of orphaning it`() { + // Nothing downstream cleans up after a failed start - the controller's and the + // provisioner's failure arms only report - so the child would survive holding its heap + // and later fire the death listener for a session that never had a daemon. + val paths = + scriptedPaths( + """ + printf '%s' "${'$'}${'$'}" > '$tmp/daemon.pid' + read line + printf '%s\n' '{"id":1,"ok":true,"protocolVersion":99}' + read line + printf '%s\n' '{"id":2,"ok":true}' + """.trimIndent(), + ) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val client = DaemonProcessClient(paths, scope, requestTimeoutMillis = 2_000) + try { + val reply = runBlocking { client.start(config()) } + assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + assertThat(client.isRunning).isFalse() + assertThat(isProcessAlive(File(tmp, "daemon.pid").readText().trim())).isFalse() + } finally { + runBlocking { client.shutdown() } + scope.cancel() + } + } + + @Test + fun `a daemon that rejects configure is shut down too`() { + val paths = + scriptedPaths( + """ + printf '%s' "${'$'}${'$'}" > '$tmp/daemon.pid' + read line + printf '%s\n' '{"id":1,"ok":false,"diagnostics":[]}' + read line + printf '%s\n' '{"id":2,"ok":true}' + """.trimIndent(), + ) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val client = DaemonProcessClient(paths, scope, requestTimeoutMillis = 2_000) + try { + val reply = runBlocking { client.start(config()) } + assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + assertThat(isProcessAlive(File(tmp, "daemon.pid").readText().trim())).isFalse() + } finally { + runBlocking { client.shutdown() } + scope.cancel() + } + } + + @Test + fun `a response with an unreadable id does not kill the response pump`() { + // A non-numeric and a nested id both throw out of the pump's forEachLine, which the + // surrounding IOException catch does not handle - unguarded, the pump dies and every + // later request burns its full timeout while still reporting the daemon alive. + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":"two","ok":true}' + printf '%s\n' '{"id":{"nested":2},"ok":true}' + printf '%s\n' '{"id":2,"ok":true,"classesDir":"/out/classes"}' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent(), + ) + + val reply = + withClient(paths, timeoutMillis = 3_000) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.compile(emptyList(), emptyList()) + } + + assertThat((reply as DaemonReply.Ok).value.classesDir).isEqualTo(File("/out/classes")) + } + + @Test + fun `a non-primitive ok is a build failure instead of an exception`() { + // asBoolean on an object throws, and this facade promises never to throw for a build + // problem - the exception escaped request() straight out of compile(). + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":{"really":true}}' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.compile(emptyList(), emptyList()) + } + + assertThat(reply).isInstanceOf(DaemonReply.BuildFailed::class.java) + assertThat((reply as DaemonReply.BuildFailed).diagnostics).isEmpty() + } + + @Test + fun `a respawn does not fire the death listener for the child it replaced`() { + // Every child answers the polite shutdown and then ignores stdin EOF, so shutdown() has + // to destroyForcibly it - which returns before the exit. The replaced child's death + // watcher therefore wakes around the moment start() installs the replacement, straddling + // the two reads it makes: the identity guard, then the deliberate-stop decision. One + // shared flag let start() reset it between those reads, so the watcher reported a death + // for a daemon that was deliberately replaced (and cleared the new session's pending + // configure with it). A per-spawn marker makes that unreadable rather than unlikely. + // + // The cycle repeats because losing that race is a scheduling accident - a single-cycle + // test can pass by luck and certify a regression as fixed. Each pass is an independent + // shot at the same interleaving; the client must be green on every one of them however + // the threads land. + val respawns = 4 + val paths = + replyingPaths( + """ + read line + reply "${'$'}line" + read line + reply "${'$'}line" + exec sleep 60 + """.trimIndent(), + ) + var died = false + // Own scope so the test can join the client's coroutines - including every replaced + // child's death watcher - instead of sleeping and hoping. + val supervisor = SupervisorJob() + val scope = CoroutineScope(supervisor + Dispatchers.IO) + val client = DaemonProcessClient(paths, scope, requestTimeoutMillis = 2_000) + + try { + runBlocking { + client.setDeathListener { died = true } + check(client.start(config()) is DaemonReply.Ok) + repeat(respawns) { + assertThat(client.start(config())).isEqualTo(DaemonReply.Ok(Unit)) + } + client.shutdown() + withTimeout(60_000) { supervisor.children.toList().forEach { it.join() } } + } + assertThat(died).isFalse() + } finally { + runBlocking { client.shutdown() } + scope.cancel() + } + } + + @Test + fun `each spawn gets its own deliberate-stop marker`() { + // The respawn test above can only catch a shared flag when the threads interleave badly; + // this pins the mechanism that removes the race, and does it on every run. shutdown() + // must mark the child it is stopping, and start() must install a NEW marker rather than + // clear that one - the replaced child's watcher goes on reading the old instance. + val paths = + replyingPaths( + """ + while read line; do + reply "${'$'}line" + done + """.trimIndent(), + ) + + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + val first = client.stopMarker() + check(client.start(config()) is DaemonReply.Ok) + val second = client.stopMarker() + + assertThat(second).isNotSameInstanceAs(first) + assertThat(first.get()).isTrue() + assertThat(second.get()).isFalse() + } + } + + @Test + fun `a daemon that dies after a restart still fires the death listener`() { + // The mirror of the respawn test, and why start() installs a fresh marker instead of + // leaving the stopped child's one in place: suppressing a later child's real death is + // the failure mode a "never clear it" fix would introduce, and it is the worse one - + // the session would sit on a dead daemon with nothing to trigger the respawn. + // + // The second child exits only after reading the ping, so its configure has certainly + // been answered first: no interleaving decides what this test observes. + val paths = + replyingPaths( + """ + if [ -f '$tmp/first-spawn' ]; then + read line + reply "${'$'}line" + read line + exit 7 + fi + : > '$tmp/first-spawn' + while read line; do + reply "${'$'}line" + done + """.trimIndent(), + ) + val deaths = CopyOnWriteArrayList() + val latch = CountDownLatch(1) + val supervisor = SupervisorJob() + val scope = CoroutineScope(supervisor + Dispatchers.IO) + val client = DaemonProcessClient(paths, scope, requestTimeoutMillis = 2_000) + + try { + runBlocking { + client.setDeathListener { code -> + deaths.add(code) + latch.countDown() + } + check(client.start(config()) is DaemonReply.Ok) + client.shutdown() + // Join the stopped child's readers before spawning its successor: pending and + // configured are shared across spawns, so a watcher still in flight could fail + // the second configure and turn a listener assertion into a spawn failure. + withTimeout(30_000) { supervisor.children.toList().forEach { it.join() } } + check(client.start(config()) is DaemonReply.Ok) + assertThat(client.ping()).isFalse() + assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue() + } + } finally { + runBlocking { client.shutdown() } + scope.cancel() + } + + // Exactly one death: the deliberate shutdown of the first child reported nothing. + assertThat(deaths).containsExactly(7) + } + + @Test + fun `a response missing the ok field is a build failure, not a success`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2}' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.dex(emptyList()) + } + + assertThat(reply).isInstanceOf(DaemonReply.BuildFailed::class.java) + assertThat((reply as DaemonReply.BuildFailed).diagnostics).isEmpty() + } + + @Test + fun `relink sends each optional field independently`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s' "${'$'}line" > '$tmp/relink-ids-only.txt' + printf '%s\n' '{"id":2,"ok":true,"resourcesArsc":"/out/res/linked-res.apk"}' + read line + printf '%s' "${'$'}line" > '$tmp/relink-flats-only.txt' + printf '%s\n' '{"id":3,"ok":true,"resourcesArsc":"/out/res/linked-res.apk"}' + read line + printf '%s\n' '{"id":4,"ok":true}' + """.trimIndent(), + ) + + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + check( + client.relink( + RelinkInputs( + resDirs = listOf(File(tmp, "res")), + manifest = File(tmp, "AndroidManifest.xml"), + stableIdsFile = File(tmp, "stableIds.txt"), + ), + ) is DaemonReply.Ok, + ) + check( + client.relink( + RelinkInputs( + resDirs = listOf(File(tmp, "res")), + manifest = File(tmp, "AndroidManifest.xml"), + libraryResources = listOf(File(tmp, "lib.flat")), + ), + ) is DaemonReply.Ok, + ) + } + + val idsOnly = File(tmp, "relink-ids-only.txt").readText() + assertThat(idsOnly).contains("stableIds") + assertThat(idsOnly).doesNotContain("libraryResources") + val flatsOnly = File(tmp, "relink-flats-only.txt").readText() + assertThat(flatsOnly).doesNotContain("stableIds") + assertThat(flatsOnly).contains("libraryResources") + } + + @Test + fun `shutdown before start is a no-op`() { + val paths = scriptedPaths("read line") + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val client = DaemonProcessClient(paths, scope) + try { + runBlocking { client.shutdown() } + assertThat(client.isRunning).isFalse() + } finally { + scope.cancel() + } + } + + @Test + fun `shutdown force-kills a daemon that ignores the polite stop`() { + // The script never reads the shutdown request and never exits on stdin EOF; the + // client must escalate to destroyForcibly instead of hanging. + val paths = + scriptedPaths( + """ + trap '' TERM + read line + printf '%s\n' '${okConfigure()}' + sleep 60 + """.trimIndent(), + ) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val client = DaemonProcessClient(paths, scope, requestTimeoutMillis = 300) + try { + runBlocking { + check(client.start(config()) is DaemonReply.Ok) + val elapsed = + kotlin.system.measureTimeMillis { + client.shutdown() + } + // Polite request times out (3s cap) + 2s waitFor, then the hard kill; well + // under the script's 60s sleep. + assertThat(elapsed).isLessThan(30_000) + } + assertThat(client.isRunning).isFalse() + } finally { + runBlocking { client.shutdown() } + scope.cancel() + } + } + + @Test + fun `the configure request carries the project's dex min API`() { + // The daemon defaults to its own floor when the key is absent, so a project whose + // seed payload was dexed at another level needs the value actually on the wire - + // a config field that never reaches the daemon leaves the baseline and its + // increments desugared against different targets. + val paths = + scriptedPaths( + """ + read line + printf '%s' "${'$'}line" > '$tmp/configure-request.txt' + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":true}' + """.trimIndent(), + ) + + withClient(paths) { client -> + check(client.start(config(minApi = 26)) is DaemonReply.Ok) + } + + val configureRequest = File(tmp, "configure-request.txt").readText() + assertThat(configureRequest).contains("\"minApi\":26") + } + + @Test + fun `the configure request states the min API even at the protocol default`() { + // Sending it unconditionally is what makes the daemon's own fallback dead code + // rather than a second, silently-diverging source of the level. + val paths = + scriptedPaths( + """ + read line + printf '%s' "${'$'}line" > '$tmp/configure-request.txt' + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":true}' + """.trimIndent(), + ) + + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + } + + assertThat(File(tmp, "configure-request.txt").readText()) + .contains("\"minApi\":${ConfigureRequest.DEFAULT_MIN_API}") + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientTest.kt new file mode 100644 index 0000000000..9d3f9bcf50 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientTest.kt @@ -0,0 +1,244 @@ +package org.appdevforall.cotg.quickbuild.data + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** + * Drives the real [DaemonProcessClient] against a scripted fake daemon: a shell script + * stands in for the java binary, replies to the configure request (id 1, the client's + * first request) with a canned line, answers the shutdown request (id 2), then exits. + * Exercises the client's actual process + protocol plumbing, not a mock. + */ +class DaemonProcessClientTest { + @TempDir + lateinit var tmp: File + + private class ScriptedPaths( + base: File, + override val javaBinary: File, + ) : QuickBuildPaths { + override val daemonJar = File(base, "daemon/quickbuild-daemon.jar") + override val runtimeAar = File(base, "quickbuild-runtime.aar") + override val aapt2 = File(base, "aapt2") + override val d8Jar = File(base, "d8.jar") + override val composeCompilerPlugin = File(base, "compose-compiler-plugin.jar") + override val androidJar = File(base, "android.jar") + override val projectScratchRoot = File(base, "app-private/quickbuild-scratch") + + // The client clears the child env; give the script a PATH for its utilities. + override fun daemonEnvironment(): Map = mapOf("PATH" to "/usr/bin:/bin") + } + + private fun pathsWithFakeDaemon(configureReplyJson: String): ScriptedPaths { + val script = File(tmp, "fake-java.sh") + script.writeText( + """ + #!/bin/sh + read line + printf '%s\n' '$configureReplyJson' + read line + printf '%s\n' '{"id":2,"ok":true}' + """.trimIndent() + "\n", + ) + script.setExecutable(true) + File(tmp, "daemon").mkdirs() + return ScriptedPaths(tmp, script) + } + + private fun config(): DaemonConfig = + DaemonConfig( + projectRoot = tmp, + classpath = emptyList(), + outDir = File(tmp, "out"), + aapt2 = File(tmp, "aapt2"), + d8Jar = File(tmp, "d8.jar"), + androidJar = File(tmp, "android.jar"), + ) + + private fun startAgainst(configureReplyJson: String): DaemonReply { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val client = DaemonProcessClient(pathsWithFakeDaemon(configureReplyJson), scope) + return try { + runBlocking { client.start(config()) } + } finally { + runBlocking { client.shutdown() } + scope.cancel() + } + } + + @Test + fun `matching protocol version configures ok`() { + val reply = + startAgainst( + """{"id":1,"ok":true,"protocolVersion":${DaemonProcessClient.EXPECTED_PROTOCOL_VERSION}}""", + ) + + assertThat(reply).isEqualTo(DaemonReply.Ok(Unit)) + } + + @Test + fun `mismatched protocol version fails configure naming both versions`() { + val reply = startAgainst("""{"id":1,"ok":true,"protocolVersion":99}""") + + assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + val message = (reply as DaemonReply.Failed).message + assertThat(message).contains("99") + assertThat(message).contains(DaemonProcessClient.EXPECTED_PROTOCOL_VERSION.toString()) + } + + /** + * Starts the client against a daemon scripted to answer configure (id 1), then one + * build op (id 2), then shutdown (id 3), and runs [op] against it. + */ + private fun withScriptedOp( + configureReplyJson: String, + opReplyJson: String, + op: suspend (DaemonProcessClient) -> DaemonReply, + ): DaemonReply { + val script = File(tmp, "fake-java.sh") + script.writeText( + """ + #!/bin/sh + read line + printf '%s\n' '$configureReplyJson' + read line + printf '%s\n' '$opReplyJson' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent() + "\n", + ) + script.setExecutable(true) + File(tmp, "daemon").mkdirs() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val client = DaemonProcessClient(ScriptedPaths(tmp, script), scope) + return try { + runBlocking { + check(client.start(config()) is DaemonReply.Ok) { "scripted configure failed" } + op(client) + } + } finally { + runBlocking { client.shutdown() } + scope.cancel() + } + } + + private fun okConfigure(extra: String = "") = + """{"id":1,"ok":true,"protocolVersion":${DaemonProcessClient.EXPECTED_PROTOCOL_VERSION}$extra}""" + + @Test + fun `compile reply carries the daemon's phase stats`() { + val reply = + withScriptedOp( + okConfigure(), + """{"id":2,"ok":true,"classesDir":"/out/classes","kotlinMillis":300,"javaMillis":2900, + "preSnapMillis":120,"postSnapMillis":130,"javaAbiSnapMillis":540,"nAllSources":292, + "nKotlinToCompile":0,"nJavaSources":218,"nChangedClasses":323,"compileOrdinal":4}""".replace("\n", "") + .replace("\t", ""), + ) { it.compile(emptyList(), emptyList()) } + + val stats = (reply as DaemonReply.Ok).value.stats!! + assertThat(stats.preSnapMillis).isEqualTo(120) + assertThat(stats.postSnapMillis).isEqualTo(130) + assertThat(stats.javaAbiSnapMillis).isEqualTo(540) + assertThat(stats.allSources).isEqualTo(292) + assertThat(stats.kotlinToCompile).isEqualTo(0) + assertThat(stats.javaSources).isEqualTo(218) + assertThat(stats.changedClasses).isEqualTo(323) + assertThat(stats.compileOrdinal).isEqualTo(4) + } + + @Test + fun `dex reply carries the class counts the pass moved`() { + val reply = + withScriptedOp( + okConfigure(), + """{"id":2,"ok":true,"dexFile":"/out/dex/classes.dex","stripMillis":5492,"d8Millis":3104,""" + + """"nClassFiles":464,"classBytes":1530112}""", + ) { it.dex(emptyList()) } + + val output = (reply as DaemonReply.Ok).value + assertThat(output.stripMillis).isEqualTo(5492) + assertThat(output.stats!!.classFiles).isEqualTo(464) + assertThat(output.stats!!.classBytes).isEqualTo(1_530_112) + } + + @Test + fun `a daemon predating the stats leaves them null rather than zero`() { + // Version-safety in the direction that actually happens: a STAGED daemon jar older + // than the client. Absent keys must read as "not measured" so the residual is not + // computed against fabricated zeros. + val compile = + withScriptedOp(okConfigure(), """{"id":2,"ok":true,"classesDir":"/out/classes"}""") { + it.compile(emptyList(), emptyList()) + } + val dex = + withScriptedOp(okConfigure(), """{"id":2,"ok":true,"dexFile":"/out/dex/classes.dex"}""") { + it.dex(emptyList()) + } + + assertThat((compile as DaemonReply.Ok).value.stats).isNull() + assertThat((dex as DaemonReply.Ok).value.stats).isNull() + } + + @Test + fun `configure captures the scratch filesystem for the session`() { + val script = File(tmp, "fake-java.sh") + script.writeText( + """ + #!/bin/sh + read line + printf '%s\n' '${okConfigure(""","scratchFsType":"fuse"""")}' + read line + printf '%s\n' '{"id":2,"ok":true}' + """.trimIndent() + "\n", + ) + script.setExecutable(true) + File(tmp, "daemon").mkdirs() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val client = DaemonProcessClient(ScriptedPaths(tmp, script), scope) + try { + runBlocking { client.start(config()) } + assertThat(client.scratchFsType).isEqualTo("fuse") + } finally { + runBlocking { client.shutdown() } + scope.cancel() + } + } + + @Test + fun `a configure that never succeeds reports no scratch filesystem`() { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val client = + DaemonProcessClient( + pathsWithFakeDaemon("""{"id":1,"ok":true,"protocolVersion":99,"scratchFsType":"fuse"}"""), + scope, + ) + try { + runBlocking { client.start(config()) } + // A rejected daemon's filesystem must not be stamped onto the next session's rows. + assertThat(client.scratchFsType).isNull() + } finally { + runBlocking { client.shutdown() } + scope.cancel() + } + } + + @Test + fun `missing protocol version fails configure`() { + // The daemon has stamped protocolVersion into configure responses since the + // protocol existed; an absent field means an alien daemon, not an old one. + val reply = startAgainst("""{"id":1,"ok":true}""") + + assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + val message = (reply as DaemonReply.Failed).message + assertThat(message).contains(DaemonProcessClient.EXPECTED_PROTOCOL_VERSION.toString()) + assertThat(message).contains("no protocolVersion") + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreEdgeTest.kt new file mode 100644 index 0000000000..854f1fa4f7 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreEdgeTest.kt @@ -0,0 +1,66 @@ +package org.appdevforall.cotg.quickbuild.data + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.io.IOException + +/** + * The rename-fallback path of [FileGenerationStore.save] (delete-then-retry, for + * filesystems where rename-over-existing fails) and the load guard for a path that + * exists but is not a file. + */ +class FileGenerationStoreEdgeTest { + @TempDir lateinit var tmp: File + + @Test + fun `a generation path that is a directory loads as null`() { + val dir = File(tmp, "generation").apply { mkdirs() } + + assertThat(FileGenerationStore(dir).load()).isNull() + } + + /** + * Only a path that IS a file and still fails to open exercises the IOException guard, + * which keeps an unreadable state file from taking the session down: a lost counter costs + * one full rebuild, a throw here costs the feature. + * + * chmod 000 is not usable as the fixture - root (what container CI runs as) bypasses the + * read bit, so the test would skip exactly where the guard matters. + */ + @Test + fun `a generation file that cannot be read starts fresh instead of throwing`() { + val unopenable = + object : File(tmp, "generation") { + override fun isFile(): Boolean = true + } + + assertThat(FileGenerationStore(unopenable).load()).isNull() + } + + @Test + fun `save falls back to delete-then-rename when the direct rename is refused`() { + // An empty directory at the target defeats the direct rename (a file cannot + // rename over a directory) but can be deleted - the retry must then land. + val target = File(tmp, "generation").apply { mkdirs() } + val store = FileGenerationStore(target) + + store.save(42) + + assertThat(target.isFile).isTrue() + assertThat(store.load()).isEqualTo(42) + } + + @Test + fun `save throws when the target cannot be replaced at all`() { + // A NON-empty directory defeats both the rename and the delete; the store must + // say so rather than silently keep the old state. + val target = File(tmp, "generation").apply { mkdirs() } + File(target, "occupant.txt").writeText("in the way") + val store = FileGenerationStore(target) + + assertThrows(IOException::class.java) { store.save(42) } + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt new file mode 100644 index 0000000000..4fb83406f2 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt @@ -0,0 +1,70 @@ +package org.appdevforall.cotg.quickbuild.data + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class FileGenerationStoreTest { + @TempDir lateinit var tempDir: File + + private fun store(name: String = "generation") = FileGenerationStore(File(tempDir, name)) + + @Test + fun `round trips a generation`() { + val store = store() + store.save(42) + assertThat(store.load()).isEqualTo(42) + } + + @Test + fun `missing file loads as null`() { + assertThat(store().load()).isNull() + } + + @Test + fun `corrupt file loads as null instead of throwing`() { + val file = File(tempDir, "generation") + file.writeText("not-a-number") + assertThat(FileGenerationStore(file).load()).isNull() + } + + @Test + fun `empty file loads as null`() { + val file = File(tempDir, "generation") + file.writeText("") + assertThat(FileGenerationStore(file).load()).isNull() + } + + @Test + fun `save creates missing parent directories`() { + val file = File(tempDir, "nested/dirs/generation") + val store = FileGenerationStore(file) + store.save(7) + assertThat(file.readText().trim()).isEqualTo("7") + } + + @Test + fun `save overwrites the previous value`() { + val store = store() + store.save(1) + store.save(2) + assertThat(store.load()).isEqualTo(2) + } + + @Test + fun `whitespace around the number is tolerated`() { + val file = File(tempDir, "generation") + file.writeText(" 13\n") + assertThat(FileGenerationStore(file).load()).isEqualTo(13) + } + + @Test + fun `forProject uses the canonical androidide state path`() { + val projectRoot = File(tempDir, "project") + val store = FileGenerationStore.forProject(projectRoot) + store.save(3) + assertThat(File(projectRoot, ".androidide/quickbuild/generation").readText().trim()) + .isEqualTo("3") + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfoEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfoEdgeTest.kt new file mode 100644 index 0000000000..e04051ae06 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfoEdgeTest.kt @@ -0,0 +1,280 @@ +package org.appdevforall.cotg.quickbuild.data + +import com.google.common.truth.Truth.assertThat +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind +import org.junit.jupiter.api.Test +import java.io.File + +/** + * Malformed-input and alias/fallback paths of [ProxyAppInfo.parse], complementing + * [ProxyAppInfoTest]'s happy paths: a setup.json written by any past or future plugin + * version must either parse to the right value or fail to null - never crash. + */ +class ProxyAppInfoEdgeTest { + private val baseDir = File("/project") + + private fun json(extra: String = "") = + """ + { + "proxyAppId": "com.example.app.quickbuild", + "entryActivity": "com.example.app.MainActivity", + "apkPath": "/apk/app-debug.apk" + $extra + } + """.trimIndent() + + @Test + fun `non-JSON text parses to null`() { + assertThat(ProxyAppInfo.parse("not json at all", baseDir)).isNull() + } + + @Test + fun `a JSON array is not a setup object`() { + assertThat(ProxyAppInfo.parse("""["proxyAppId"]""", baseDir)).isNull() + } + + @Test + fun `missing proxyAppId is a parse failure`() { + val text = """{"entryActivity":"com.example.Main","apkPath":"/apk/app.apk"}""" + + assertThat(ProxyAppInfo.parse(text, baseDir)).isNull() + } + + @Test + fun `missing apk is a parse failure`() { + val text = """{"proxyAppId":"com.example.app.quickbuild"}""" + + assertThat(ProxyAppInfo.parse(text, baseDir)).isNull() + } + + @Test + fun `a blank proxyAppId falls through to the next alias`() { + val text = + """{"proxyAppId":" ","testAppId":"com.example.legacy","apk":"/apk/app.apk"}""" + + val info = ProxyAppInfo.parse(text, baseDir) + + assertThat(info!!.proxyAppPackage).isEqualTo("com.example.legacy") + } + + @Test + fun `a non-primitive alias value falls through to the next alias`() { + val text = + """{"proxyAppId":{"v":1},"applicationId":"com.example.obj","apkFile":"/apk/app.apk"}""" + + val info = ProxyAppInfo.parse(text, baseDir) + + assertThat(info!!.proxyAppPackage).isEqualTo("com.example.obj") + assertThat(info.apk).isEqualTo(File("/apk/app.apk")) + } + + @Test + fun `relative paths resolve against the base dir and absolute paths stand`() { + val info = + ProxyAppInfo.parse( + json( + """ + , + "classpath": ["libs/a.jar", "/abs/b.jar"], + "proxyClassesDir": "build/proxy-classes", + "manifestPath": "/abs/AndroidManifest.xml" + """.trimIndent(), + ), + baseDir, + ) + + assertThat(info!!.classpath) + .containsExactly(File("/project/libs/a.jar"), File("/abs/b.jar")) + .inOrder() + assertThat(info.proxyClassesDir).isEqualTo(File("/project/build/proxy-classes")) + assertThat(info.transformedManifest).isEqualTo(File("/abs/AndroidManifest.xml")) + } + + @Test + fun `payloadJars ride the classpath after the compile classpath`() { + val info = + ProxyAppInfo.parse( + json(""","classpath": ["libs/a.jar"], "payloadJars": ["build/R.jar", {"bad": 1}]"""), + baseDir, + ) + + assertThat(info!!.classpath) + .containsExactly(File("/project/libs/a.jar"), File("/project/build/R.jar")) + .inOrder() + } + + @Test + fun `non-primitive classpath entries are dropped`() { + val info = ProxyAppInfo.parse(json(""","classpath": [["nested"], "libs/a.jar"]"""), baseDir) + + assertThat(info!!.classpath).containsExactly(File("/project/libs/a.jar")) + } + + @Test + fun `optional file fields default to null when absent`() { + val info = ProxyAppInfo.parse(json(), baseDir) + + assertThat(info!!.proxyClassesDir).isNull() + assertThat(info.transformedManifest).isNull() + } + + @Test + fun `transformedManifest alias parses too`() { + val info = ProxyAppInfo.parse(json(""","transformedManifest": "build/Merged.xml""""), baseDir) + + assertThat(info!!.transformedManifest).isEqualTo(File("/project/build/Merged.xml")) + } + + @Test + fun `a numeric composeEnabled reads as false`() { + val info = ProxyAppInfo.parse(json(""","composeEnabled": 1"""), baseDir) + + assertThat(info!!.composeEnabled).isFalse() + } + + @Test + fun `a non-numeric schema reads as the pre-v2 baseline`() { + val info = ProxyAppInfo.parse(json(""","schema": "2""""), baseDir) + + assertThat(info!!.schema).isEqualTo(0) + assertThat(info.supportsComponentInfo).isFalse() + } + + @Test + fun `schema at the component version supports component info`() { + val info = ProxyAppInfo.parse(json(""","schema": ${ProxyAppInfo.COMPONENT_SCHEMA_VERSION}"""), baseDir) + + assertThat(info!!.supportsComponentInfo).isTrue() + } + + @Test + fun `blank and non-primitive annotationProcessors entries are dropped`() { + val info = + ProxyAppInfo.parse( + json(""","annotationProcessors": ["androidx.room:room-compiler", " ", {"o":1}]"""), + baseDir, + ) + + assertThat(info!!.annotationProcessors).containsExactly("androidx.room:room-compiler") + } + + @Test + fun `every declared component kind parses to its enum`() { + val info = + ProxyAppInfo.parse( + json( + """ + , + "schema": 2, + "components": [ + {"type": "activity", "userClass": "com.example.A"}, + {"type": "service", "userClass": "com.example.S"}, + {"type": "receiver", "userClass": "com.example.R"}, + {"type": "provider", "userClass": "com.example.P"}, + {"type": "application", "userClass": "com.example.App"} + ] + """.trimIndent(), + ), + baseDir, + ) + + assertThat(info!!.components.map { it.kind }) + .containsExactly( + ComponentKind.ACTIVITY, + ComponentKind.SERVICE, + ComponentKind.RECEIVER, + ComponentKind.PROVIDER, + ComponentKind.APPLICATION, + ).inOrder() + } + + @Test + fun `a component with a non-boolean launcher parses as not launcher`() { + val info = + ProxyAppInfo.parse( + json( + ""","components": [{"type": "activity", "userClass": "com.example.A", "launcher": "yes"}]""", + ), + baseDir, + ) + + assertThat(info!!.components.single().launcher).isFalse() + } + + @Test + fun `component supertypes drop non-primitive entries`() { + val info = + ProxyAppInfo.parse( + json( + ""","components": [{"type": "activity", "userClass": "com.example.A",""" + + """"supertypes": ["android.app.Activity", {"o":1}]}]""", + ), + baseDir, + ) + + assertThat(info!!.components.single().supertypes).containsExactly("android.app.Activity") + } + + @Test + fun `a component without supertypes parses with none`() { + val info = + ProxyAppInfo.parse( + json(""","components": [{"type": "activity", "userClass": "com.example.A"}]"""), + baseDir, + ) + + val component = info!!.components.single() + assertThat(component.supertypes).isEmpty() + assertThat(component.proxyClass).isNull() + } + + @Test + fun `an explicit composeEnabled false parses as false`() { + val info = ProxyAppInfo.parse(json(""","composeEnabled": false"""), baseDir) + + assertThat(info!!.composeEnabled).isFalse() + } + + @Test + fun `a JSON-null schema reads as the pre-v2 baseline`() { + val info = ProxyAppInfo.parse(json(""","schema": null"""), baseDir) + + assertThat(info!!.schema).isEqualTo(0) + } + + @Test + fun `a component with an explicit launcher false parses as not launcher`() { + val info = + ProxyAppInfo.parse( + json( + ""","components": [{"type": "activity", "userClass": "com.example.A", "launcher": false}]""", + ), + baseDir, + ) + + assertThat(info!!.components.single().launcher).isFalse() + } + + @Test + fun `a JSON-null alias value falls through to the next alias`() { + val text = + """{"proxyAppId": null, "testAppPackage": "com.example.nulled", "apk": "/apk/app.apk"}""" + + val info = ProxyAppInfo.parse(text, baseDir) + + assertThat(info!!.proxyAppPackage).isEqualTo("com.example.nulled") + } + + @Test + fun `sourceRoots resolve against the base dir`() { + val info = + ProxyAppInfo.parse( + json(""","sourceRoots": ["src/main/java", "/abs/generated"]"""), + baseDir, + ) + + assertThat(info!!.sourceRoots) + .containsExactly(File("/project/src/main/java"), File("/abs/generated")) + .inOrder() + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfoTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfoTest.kt new file mode 100644 index 0000000000..04854093f6 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfoTest.kt @@ -0,0 +1,299 @@ +package org.appdevforall.cotg.quickbuild.data + +import com.google.common.truth.Truth.assertThat +import org.appdevforall.cotg.quickbuild.protocol.ConfigureRequest +import org.junit.jupiter.api.Test +import java.io.File + +class ProxyAppInfoTest { + private val baseDir = File("/project") + + private fun json(extra: String = "") = + """ + { + "proxyAppId": "com.example.app.quickbuild", + "entryActivity": "com.example.app.MainActivity", + "apkPath": "/apk/app-debug.apk" + $extra + } + """.trimIndent() + + @Test + fun `composeEnabled true parses through`() { + val info = ProxyAppInfo.parse(json(""","composeEnabled": true"""), baseDir) + + assertThat(info).isNotNull() + assertThat(info!!.composeEnabled).isTrue() + } + + @Test + fun `composeEnabled defaults to false when absent`() { + val info = ProxyAppInfo.parse(json(), baseDir) + + assertThat(info).isNotNull() + assertThat(info!!.composeEnabled).isFalse() + } + + @Test + fun `composeEnabled tolerates a non-boolean value`() { + val info = ProxyAppInfo.parse(json(""","composeEnabled": "yes""""), baseDir) + + assertThat(info).isNotNull() + assertThat(info!!.composeEnabled).isFalse() + } + + @Test + fun `minApi parses through so increments are dexed like the baseline`() { + val info = ProxyAppInfo.parse(json(""","minApi": 33"""), baseDir) + + assertThat(info).isNotNull() + assertThat(info!!.minApi).isEqualTo(33) + } + + @Test + fun `minApi falls back to the protocol floor on a setup json that predates the field`() { + val info = ProxyAppInfo.parse(json(), baseDir) + + assertThat(info).isNotNull() + assertThat(info!!.minApi).isEqualTo(ConfigureRequest.DEFAULT_MIN_API) + } + + @Test + fun `minApi tolerates a non-numeric value`() { + val info = ProxyAppInfo.parse(json(""","minApi": "thirty""""), baseDir) + + assertThat(info).isNotNull() + assertThat(info!!.minApi).isEqualTo(ConfigureRequest.DEFAULT_MIN_API) + } + + @Test + fun `pre-v2 setup json parses with schema 0 and no components`() { + val info = ProxyAppInfo.parse(json(), baseDir) + + assertThat(info).isNotNull() + assertThat(info!!.schema).isEqualTo(0) + assertThat(info.components).isEmpty() + } + + @Test + fun `v2 components parse with kind, proxy, launcher and supertypes`() { + val info = + ProxyAppInfo.parse( + json( + """, + "schema": 2, + "components": [ + {"type": "activity", "userClass": "com.example.app.MainActivity", + "proxyClass": "com.example.app.quickbuild.proxies.Proxy0Activity", + "launcher": true, "supertypes": ["com.example.app.BaseActivity"]}, + {"type": "service", "userClass": "com.example.app.SyncService", + "proxyClass": "com.example.app.quickbuild.proxies.Proxy0Service", + "foregroundServiceType": "dataSync", "supertypes": []}, + {"type": "application", "userClass": "com.example.app.App"} + ] + """, + ), + baseDir, + ) + + assertThat(info).isNotNull() + assertThat(info!!.schema).isEqualTo(2) + assertThat(info.components).hasSize(3) + + val (activity, service, application) = info.components + assertThat(activity.kind).isEqualTo(org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind.ACTIVITY) + assertThat(activity.className).isEqualTo("com.example.app.MainActivity") + assertThat(activity.proxyClass).isEqualTo("com.example.app.quickbuild.proxies.Proxy0Activity") + assertThat(activity.launcher).isTrue() + assertThat(activity.supertypes).containsExactly("com.example.app.BaseActivity") + + assertThat(service.kind).isEqualTo(org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind.SERVICE) + assertThat(service.launcher).isFalse() + + assertThat(application.kind) + .isEqualTo(org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind.APPLICATION) + assertThat(application.proxyClass).isNull() + } + + @Test + fun `unknown component type is skipped, not fatal`() { + val info = + ProxyAppInfo.parse( + json( + """, + "schema": 2, + "components": [ + {"type": "hologram", "userClass": "com.example.app.Future"}, + {"type": "service", "userClass": "com.example.app.SyncService"} + ] + """, + ), + baseDir, + ) + + assertThat(info).isNotNull() + assertThat(info!!.components).hasSize(1) + assertThat(info.components.single().className).isEqualTo("com.example.app.SyncService") + } + + @Test + fun `malformed component entries are skipped`() { + val info = + ProxyAppInfo.parse( + json( + """, + "schema": 2, + "components": [ + {"type": "service"}, + "not-an-object", + {"userClass": "com.example.app.NoType"} + ] + """, + ), + baseDir, + ) + + assertThat(info).isNotNull() + assertThat(info!!.components).isEmpty() + } + + @Test + fun `annotation processors and source roots parse through`() { + val info = + ProxyAppInfo.parse( + json( + """ + , + "annotationProcessors": ["androidx.room:room-compiler:2.6.1", " "], + "sourceRoots": ["app/src/main/java", "/abs/build/generated/ksp/debug/kotlin"] + """.trimIndent(), + ), + baseDir, + ) + + assertThat(info).isNotNull() + assertThat(info!!.annotationProcessors).containsExactly("androidx.room:room-compiler:2.6.1") + assertThat(info.sourceRoots) + .containsExactly( + File("/project/app/src/main/java"), + File("/abs/build/generated/ksp/debug/kotlin"), + ).inOrder() + } + + @Test + fun `annotation processors and source roots default to empty`() { + val info = ProxyAppInfo.parse(json(), baseDir) + + assertThat(info).isNotNull() + assertThat(info!!.annotationProcessors).isEmpty() + assertThat(info.sourceRoots).isEmpty() + } + + @Test + fun `stableIdsPath parses to an absolute file resolved against the base dir`() { + val info = + ProxyAppInfo.parse( + json(""", "stableIdsPath": "app/build/intermediates/stable_resource_ids_file/debug/processDebugResources/stableIds.txt""""), + baseDir, + ) + + assertThat(info).isNotNull() + assertThat(info!!.stableIdsFile) + .isEqualTo(File("/project/app/build/intermediates/stable_resource_ids_file/debug/processDebugResources/stableIds.txt")) + } + + @Test + fun `stableIdsPath is null when the proxy app build reported none`() { + val info = ProxyAppInfo.parse(json(), baseDir) + + assertThat(info).isNotNull() + assertThat(info!!.stableIdsFile).isNull() + } + + @Test + fun `libraryResourcePaths parse to absolute files resolved against the base dir`() { + val info = + ProxyAppInfo.parse( + json( + """, "libraryResourcePaths": ["app/build/intermediates/merged_res/debug/values_values.arsc.flat", + "/root/.gradle/caches/8.14.3/transforms/abc/transformed/com.google.android.material/drawable_x.xml.flat"]""".replace( + "\n", + "", + ), + ), + baseDir, + ) + + assertThat(info).isNotNull() + assertThat(info!!.libraryResourceFlats) + .containsExactly( + File("/project/app/build/intermediates/merged_res/debug/values_values.arsc.flat"), + File("/root/.gradle/caches/8.14.3/transforms/abc/transformed/com.google.android.material/drawable_x.xml.flat"), + ).inOrder() + } + + @Test + fun `libraryResourcePaths defaults to empty when the proxy app build reported none`() { + val info = ProxyAppInfo.parse(json(), baseDir) + + assertThat(info).isNotNull() + assertThat(info!!.libraryResourceFlats).isEmpty() + } + + @Test + fun `a null entryActivity parses successfully - a successful build with no launchable Activity is not a parse failure`() { + // The plugin writes a literal JSON null for entryActivity when the project has + // no launchable Activity (e.g. the No-Activity template), so entryActivity is + // optional. Treating it as required makes parse() return null on a build that + // succeeded, which the provisioner reports as "Quick Build proxy app build + // failed". + val info = + ProxyAppInfo.parse( + """ + { + "proxyAppId": "com.example.app.quickbuild", + "entryActivity": null, + "apkPath": "/apk/app-debug.apk" + } + """.trimIndent(), + baseDir, + ) + + assertThat(info).isNotNull() + assertThat(info!!.entryActivity).isNull() + } + + @Test + fun `an absent entryActivity key parses successfully as null too`() { + val info = + ProxyAppInfo.parse( + """ + { + "proxyAppId": "com.example.app.quickbuild", + "apkPath": "/apk/app-debug.apk" + } + """.trimIndent(), + baseDir, + ) + + assertThat(info).isNotNull() + assertThat(info!!.entryActivity).isNull() + } + + @Test + fun `legacy testAppId key still parses - a setup json on device may predate the rename`() { + val info = + ProxyAppInfo.parse( + """ + { + "testAppId": "com.example.app.quickbuild", + "apkPath": "/apk/app-debug.apk" + } + """.trimIndent(), + baseDir, + ) + + assertThat(info).isNotNull() + assertThat(info!!.proxyAppPackage).isEqualTo("com.example.app.quickbuild") + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayoutTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayoutTest.kt new file mode 100644 index 0000000000..e5cdad9b4f --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayoutTest.kt @@ -0,0 +1,166 @@ +package org.appdevforall.cotg.quickbuild.data + +import com.google.common.truth.Truth.assertThat +import org.appdevforall.cotg.quickbuild.service.telemetry.report +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** The source set the daemon compiles, including processor-generated roots. */ +class QuickBuildProjectLayoutTest { + @TempDir + lateinit var root: File + + private fun write( + path: String, + text: String = "class X", + ): File = File(root, path).apply { parentFile.mkdirs() }.apply { writeText(text) } + + @Test + fun `stableIdsFile returns the proxy app build's reported file`() { + val stableIds = write("app/build/intermediates/stable_resource_ids_file/debug/processDebugResources/stableIds.txt", "") + + val layout = QuickBuildProjectLayout(root, stableIdsFile = stableIds) + + assertThat(layout.stableIdsFile()).isEqualTo(stableIds) + } + + @Test + fun `stableIdsFile is null when the proxy app build did not report one`() { + val layout = QuickBuildProjectLayout(root) + + assertThat(layout.stableIdsFile()).isNull() + } + + @Test + fun `libraryResourceFlats returns the proxy app build's reported units`() { + val mergedRes = write("app/build/intermediates/merged_res/debug/values_values.arsc.flat", "") + val libraryFile = write("gradle-cache/transformed/com.google.android.material/drawable_x.xml.flat", "") + + val layout = QuickBuildProjectLayout(root, libraryResourceFlats = listOf(mergedRes, libraryFile)) + + assertThat(layout.libraryResourceFlats()).containsExactly(mergedRes, libraryFile).inOrder() + } + + @Test + fun `libraryResourceFlats is empty when the proxy app build did not report any`() { + val layout = QuickBuildProjectLayout(root) + + assertThat(layout.libraryResourceFlats()).isEmpty() + } + + @Test + fun `collects kotlin and java sources under the main source roots`() { + write("app/src/main/java/com/example/A.java") + write("app/src/main/kotlin/com/example/B.kt") + write("app/src/main/res/values/strings.xml", "") + + val sources = QuickBuildProjectLayout(root).allSources().map { it.name } + + assertThat(sources).containsExactly("A.java", "B.kt") + } + + @Test + fun `includes generated source roots reported by the proxy app build`() { + write("app/src/main/java/com/example/A.kt") + val generated = write("app/build/generated/ksp/v8Debug/kotlin/com/example/ADao_Impl.kt") + + val sources = + QuickBuildProjectLayout( + projectRoot = root, + extraSourceRoots = listOf(File(root, "app/build/generated/ksp/v8Debug/kotlin")), + ).allSources() + + assertThat(sources.map { it.name }).containsExactly("A.kt", "ADao_Impl.kt") + assertThat(sources.map { it.absolutePath }).contains(generated.absolutePath) + } + + @Test + fun `a generated root that repeats a main root does not duplicate sources`() { + write("app/src/main/java/com/example/A.kt") + + val sources = + QuickBuildProjectLayout( + projectRoot = root, + extraSourceRoots = listOf(File(root, "app/src/main/java")), + ).allSources() + + assertThat(sources).hasSize(1) + } + + @Test + fun `a missing generated root is ignored`() { + write("app/src/main/java/com/example/A.kt") + + val sources = + QuickBuildProjectLayout( + projectRoot = root, + extraSourceRoots = listOf(File(root, "app/build/generated/ksp/v8Debug/kotlin")), + ).allSources() + + assertThat(sources.map { it.name }).containsExactly("A.kt") + } + + @Test + fun `generated roots are compiled but never watched`() { + val layout = + QuickBuildProjectLayout( + projectRoot = root, + extraSourceRoots = listOf(File(root, "app/build/generated/ksp/v8Debug/kotlin")), + ) + + // Watching build/ would feed the loop its own output. + assertThat(layout.watchedRoots()).containsExactly(File(root, "app/src")) + } + + @Test + fun `watchedRoots spans every module's src so a library edit is seen`() { + write("app/build.gradle.kts") + write("feature-login/build.gradle.kts") + write("core/ui/build.gradle") + + val roots = QuickBuildProjectLayout(root).watchedRoots() + + assertThat(roots).containsExactly( + File(root, "app/src"), + File(root, "feature-login/src"), + File(root, "core/ui/src"), + ) + } + + @Test + fun `watchedFiles includes every module's build script plus root gradle config`() { + write("app/build.gradle.kts") + write("feature-login/build.gradle") + + val watched = QuickBuildProjectLayout(root).watchedFiles() + + assertThat(watched).containsAtLeast( + File(root, "settings.gradle.kts"), + File(root, "gradle/libs.versions.toml"), + File(root, "app/build.gradle.kts"), + File(root, "feature-login/build.gradle"), + ) + } + + @Test + fun `module discovery skips build intermediates and hidden dirs`() { + write("app/build.gradle.kts") + // A stray build script under build/ or a hidden dir must NOT become a watched module. + write("app/build/generated/some-tool/build.gradle") + write(".gradle/tmp/build.gradle") + + val roots = QuickBuildProjectLayout(root).watchedRoots() + + assertThat(roots).containsExactly(File(root, "app/src")) + } + + @Test + fun `liveReloadScope is only the app module even in a multi-module project`() { + write("app/build.gradle.kts") + write("feature-login/build.gradle.kts") + + assertThat(QuickBuildProjectLayout(root).liveReloadScope()) + .containsExactly(File(root, "app/src")) + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchEdgeTest.kt new file mode 100644 index 0000000000..2ce1a73f14 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchEdgeTest.kt @@ -0,0 +1,73 @@ +package org.appdevforall.cotg.quickbuild.data + +import com.google.common.truth.Truth.assertThat +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** + * Key-sanitization and preparation edges of [QuickBuildScratch] beyond + * [QuickBuildScratchTest]: filename-safe punctuation must survive the key, a nameless + * root still yields a usable key, prepare is idempotent, and a blocked tree fails + * with the user-facing message instead of throwing. + */ +class QuickBuildScratchEdgeTest { + @TempDir lateinit var tmp: File + + private fun scratch() = QuickBuildScratch(File(tmp, "scratch-root")) + + @Test + fun `dots underscores and dashes survive sanitization`() { + val key = scratch().projectKey(File(tmp, "My.App_v2-final")) + + assertThat(key).startsWith("My.App_v2-final-") + } + + @Test + fun `a root without a name still gets a usable project key`() { + // File("/") has an empty name; the key must not start with a bare dash. + val key = scratch().projectKey(File("/")) + + assertThat(key).startsWith("project-") + } + + @Test + fun `an over-long basename is truncated but keeps the full hash`() { + val longName = "a".repeat(120) + val key = scratch().projectKey(File(tmp, longName)) + + // 32 basename chars + dash + 16 hash chars. + assertThat(key.length).isLessThan(longName.length) + assertThat(key).matches("a+-[0-9a-f]{16}") + } + + @Test + fun `prepare is idempotent on an existing tree`() { + val scratch = scratch() + val project = File(tmp, "proj").apply { mkdirs() } + val first = scratch.prepare(project) as QuickBuildScratch.Preparation.Ready + File(first.dir, "work").mkdirs() + + val second = scratch.prepare(project) + + // The existing tree (and anything in it) is kept, not recreated. + assertThat(second).isEqualTo(first) + assertThat(File(first.dir, "work").isDirectory).isTrue() + } + + @Test + fun `a tree blocked by a stray file fails with the user-facing message`() { + val scratch = scratch() + val project = File(tmp, "proj").apply { mkdirs() } + val tree = scratch.treeFor(project) + tree.parentFile!!.mkdirs() + tree.writeText("not a directory") + + val preparation = scratch.prepare(project) + + assertThat(preparation).isInstanceOf(QuickBuildScratch.Preparation.Failed::class.java) + assertThat((preparation as QuickBuildScratch.Preparation.Failed).message) + .isInstanceOf(QuickBuildMessage.ScratchDirUnavailable::class.java) + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchTest.kt new file mode 100644 index 0000000000..7457e5c56a --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchTest.kt @@ -0,0 +1,200 @@ +package org.appdevforall.cotg.quickbuild.data + +import com.google.common.truth.Truth.assertThat +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class QuickBuildScratchTest { + @TempDir lateinit var root: File + + @TempDir lateinit var projects: File + + private val scratch by lazy { QuickBuildScratch(root) } + + @Test + fun `same project maps to the same key across instances`() { + val project = File(projects, "MyApp") + val again = QuickBuildScratch(root) + + assertThat(scratch.projectKey(project)).isEqualTo(again.projectKey(project)) + assertThat(scratch.treeFor(project)).isEqualTo(again.treeFor(project)) + } + + @Test + fun `key is stable under redundant path segments`() { + val plain = File(projects, "MyApp") + val dotted = File(projects, "sub/../MyApp") + + assertThat(scratch.projectKey(dotted)).isEqualTo(scratch.projectKey(plain)) + } + + @Test + fun `distinct projects sharing a basename get distinct trees`() { + val a = File(projects, "a/MyApp") + val b = File(projects, "b/MyApp") + + assertThat(scratch.treeFor(a)).isNotEqualTo(scratch.treeFor(b)) + // Both stay directly under the root - the basename part never nests. + assertThat(scratch.treeFor(a).parentFile).isEqualTo(root) + assertThat(scratch.treeFor(b).parentFile).isEqualTo(root) + } + + @Test + fun `key sanitizes filename-hostile characters but keeps the hash`() { + val weird = File(projects, "My App (v2)!") + val key = scratch.projectKey(weird) + + assertThat(key).matches("[A-Za-z0-9._-]+") + assertThat(key).contains("My_App") + } + + @Test + fun `work and out dirs are siblings inside the project tree`() { + val project = File(projects, "MyApp") + + assertThat(scratch.workDirFor(project).parentFile).isEqualTo(scratch.treeFor(project)) + assertThat(scratch.outDirFor(project).parentFile).isEqualTo(scratch.treeFor(project)) + assertThat(scratch.workDirFor(project)).isNotEqualTo(scratch.outDirFor(project)) + } + + @Test + fun `prepare creates the tree and reports ready`() { + val project = File(projects, "MyApp") + + val prepared = scratch.prepare(project) + + assertThat(prepared).isInstanceOf(QuickBuildScratch.Preparation.Ready::class.java) + assertThat((prepared as QuickBuildScratch.Preparation.Ready).dir.isDirectory).isTrue() + assertThat(prepared.dir).isEqualTo(scratch.treeFor(project)) + } + + @Test + fun `prepare fails with a user-facing message when the volume is below the floor`() { + // A floor no real filesystem satisfies forces the shortfall branch. + val guarded = QuickBuildScratch(root, minFreeBytes = Long.MAX_VALUE) + + val prepared = guarded.prepare(File(projects, "MyApp")) + + assertThat(prepared).isInstanceOf(QuickBuildScratch.Preparation.Failed::class.java) + // Named, with the two numbers the host's copy interpolates - the wording itself + // lives in the app module's resources. + val message = (prepared as QuickBuildScratch.Preparation.Failed).message + assertThat(message).isInstanceOf(QuickBuildMessage.NotEnoughStorage::class.java) + assertThat((message as QuickBuildMessage.NotEnoughStorage).requiredMb).isGreaterThan(0L) + // The failure never half-creates the tree. + assertThat(guarded.treeFor(File(projects, "MyApp")).exists()).isFalse() + } + + @Test + fun `freeSpaceShortfall is null when the volume has room`() { + assertThat(scratch.freeSpaceShortfall()).isNull() + } + + @Test + fun `remove deletes the tree and tolerates a missing one`() { + val project = File(projects, "MyApp") + val tree = (scratch.prepare(project) as QuickBuildScratch.Preparation.Ready).dir + File(tree, "out/classes/Foo.class").apply { + parentFile!!.mkdirs() + writeText("bytecode") + } + + scratch.remove(project) + assertThat(tree.exists()).isFalse() + + // Second remove: nothing there, nothing thrown. + scratch.remove(project) + } + + @Test + fun `sweep removes every tree, including a populated one`() { + val first = File(projects, "FirstApp") + val second = File(projects, "SecondApp") + val firstTree = (scratch.prepare(first) as QuickBuildScratch.Preparation.Ready).dir + val secondTree = (scratch.prepare(second) as QuickBuildScratch.Preparation.Ready).dir + File(secondTree, "out/stale.dex").apply { + parentFile!!.mkdirs() + writeText("stale") + } + + scratch.sweep() + + assertThat(firstTree.exists()).isFalse() + assertThat(secondTree.exists()).isFalse() + } + + @Test + fun `sweep reclaims the tree of a deleted project`() { + val project = File(projects, "Doomed").apply { mkdirs() } + val tree = (scratch.prepare(project) as QuickBuildScratch.Preparation.Ready).dir + + // The project folder is gone; only the key (derived from the path string) + // remains - the sweep must still find and delete the orphan tree. + project.deleteRecursively() + scratch.sweep() + + assertThat(tree.exists()).isFalse() + } + + @Test + fun `sweep leaves stray files and tolerates a missing root`() { + val stray = File(root, "not-a-tree.txt").apply { writeText("keep me") } + scratch.sweep() + assertThat(stray.exists()).isTrue() + + root.deleteRecursively() + // Missing root: listFiles() is null; nothing thrown. + scratch.sweep() + } + + /** + * Pins [dir] shut by clearing its write bit, so nothing inside it can be unlinked and + * the non-empty directory itself cannot go either. Skips the calling test when the runner + * writes into it anyway - root, or a filesystem that ignores the bit - since there is then + * no delete failure to observe. + * + * @param dir the directory to make undeletable; it must already exist and be non-empty. + */ + private fun pinShut(dir: File) { + dir.setWritable(false) + assumeTrue(!File(dir, "write-probe").mkdirs(), "the runner can still write into a read-only dir") + } + + @Test + fun `remove reports an undeletable tree instead of throwing`() { + val project = File(projects, "Stuck") + val tree = (scratch.prepare(project) as QuickBuildScratch.Preparation.Ready).dir + val out = File(tree, "out").apply { mkdirs() } + val pinned = File(out, "pinned.class").apply { writeText("bytecode") } + pinShut(out) + + // Teardown has to finish, so a tree that will not go is logged, never propagated. + scratch.remove(project) + + assertThat(pinned.exists()).isTrue() + out.setWritable(true) + } + + @Test + fun `sweep keeps reclaiming past a tree it cannot delete`() { + val stuckTree = + (scratch.prepare(File(projects, "Stuck")) as QuickBuildScratch.Preparation.Ready).dir + val healthyTree = + (scratch.prepare(File(projects, "Healthy")) as QuickBuildScratch.Preparation.Ready).dir + val out = File(stuckTree, "out").apply { mkdirs() } + val pinned = File(out, "pinned.class").apply { writeText("bytecode") } + pinShut(out) + + scratch.sweep() + + // A stuck tree costs its own disk and nothing else. Note this pins the OUTCOME, not + // the iteration order: listFiles() decides which tree is visited first, so a sweep + // that aborted on the failure would still pass whenever the stuck tree came last. + assertThat(pinned.exists()).isTrue() + assertThat(healthyTree.exists()).isFalse() + out.setWritable(true) + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt index 97a668b076..c794d9edab 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt @@ -1,10 +1,134 @@ package org.appdevforall.cotg.quickbuild.service +import org.appdevforall.cotg.quickbuild.data.CompileOutput +import org.appdevforall.cotg.quickbuild.data.DaemonConfig +import org.appdevforall.cotg.quickbuild.data.DaemonReply +import org.appdevforall.cotg.quickbuild.data.DexOutput +import org.appdevforall.cotg.quickbuild.data.QuickBuildDaemon +import org.appdevforall.cotg.quickbuild.data.QuickBuildPaths +import org.appdevforall.cotg.quickbuild.data.RelinkInputs +import org.appdevforall.cotg.quickbuild.data.RelinkOutput import org.appdevforall.cotg.quickbuild.domain.reload.GenerationStore import org.appdevforall.cotg.quickbuild.service.deploy.DeployResult import org.appdevforall.cotg.quickbuild.service.deploy.DeploySender import java.io.File +/** Scripted [QuickBuildDaemon]: every op records its arguments and replies per script. */ +class FakeDaemon : QuickBuildDaemon { + val startConfigs = mutableListOf() + val compileCalls = mutableListOf, List>>() + + /** Removed-sources arg of each `compile`, recorded separately for Bug-12 assertions. */ + val compileRemovedFiles = mutableListOf>() + val dexCalls = mutableListOf>() + val relinkCalls = mutableListOf() + var shutdownCount = 0 + + var startReply: DaemonReply = DaemonReply.Ok(Unit) + var compileReply: DaemonReply = + DaemonReply.Ok(CompileOutput(File("/fake/classes"), changedClassFiles = emptyList())) + var dexReply: DaemonReply = DaemonReply.Ok(DexOutput(File("/fake/classes.dex"))) + var relinkReply: DaemonReply = DaemonReply.Ok(RelinkOutput(File("/fake/resources.arsc"))) + + var deathListener: ((Int) -> Unit)? = null + private set + + override var isRunning: Boolean = false + + /** Null by default, matching a daemon that reports no filesystem for its scratch tree. */ + override var scratchFsType: String? = null + + /** + * When set, the NEXT [start] parks here after recording its config, consuming the + * gate - later starts pass through. Lets a race test hold a respawn mid-start while + * something else (a rebaseline, a teardown) takes the daemon down. + */ + var startGate: kotlinx.coroutines.CompletableDeferred? = null + + /** + * Makes a gated [start] finish its wait even after the calling coroutine is cancelled. + * Models a daemon spawn already past the point of no return: cancellation is cooperative, + * so the start completes and leaves a zombie process the caller still has to stop. + */ + var startSurvivesCancel = false + + /** + * When set, the NEXT [shutdown] parks here, consuming the gate - later shutdowns pass + * through. Lets a test hold a teardown's daemon stop open while a new session goes live. + */ + var shutdownGate: kotlinx.coroutines.CompletableDeferred? = null + + /** + * Runs inside [start], after the reply is decided but before it is returned. The hook for a + * child that dies during its own spawn: call [die] here and then yield, and the death lands + * while the respawn is still in flight, which is the ordering a real spawn produces - the + * death watcher runs on its own dispatcher while `start` is suspended on IO. + */ + var onStart: suspend () -> Unit = {} + + override suspend fun start(config: DaemonConfig): DaemonReply { + startConfigs += config + startGate?.let { gate -> + startGate = null + if (startSurvivesCancel) { + kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { gate.await() } + } else { + gate.await() + } + } + if (startReply is DaemonReply.Ok) isRunning = true + onStart() + return startReply + } + + /** + * Runs inside [compile], i.e. mid-build. The hook for anything that has to land while + * a build is in flight - a tap promoting the running build, a teardown racing it. + */ + var onCompile: () -> Unit = {} + + override suspend fun compile( + allSources: List, + changedFiles: List, + removedFiles: List, + ): DaemonReply { + compileCalls += allSources to changedFiles + compileRemovedFiles += removedFiles + onCompile() + return compileReply + } + + override suspend fun dex(classesDirs: List): DaemonReply { + dexCalls += classesDirs + return dexReply + } + + override suspend fun relink(inputs: RelinkInputs): DaemonReply { + relinkCalls += inputs + return relinkReply + } + + override suspend fun ping(): Boolean = isRunning + + override suspend fun shutdown() { + shutdownGate?.let { gate -> + shutdownGate = null + gate.await() + } + shutdownCount++ + isRunning = false + } + + override fun setDeathListener(listener: ((Int) -> Unit)?) { + deathListener = listener + } + + fun die(exitCode: Int) { + isRunning = false + deathListener?.invoke(exitCode) + } +} + /** Recording [DeploySender] with a scripted result. */ class FakeDeploy : DeploySender { data class Call( @@ -67,3 +191,20 @@ class MemoryGenerationStore : GenerationStore { value = generation } } + +class FakePaths( + baseDir: File, +) : QuickBuildPaths { + override val javaBinary = File(baseDir, "jdk/bin/java") + override val daemonJar = File(baseDir, "quickbuild/daemon/quickbuild-daemon.jar") + override val runtimeAar = File(baseDir, "quickbuild/quickbuild-runtime.aar") + override val aapt2 = File(baseDir, "sdk/aapt2") + override val d8Jar = File(baseDir, "sdk/d8.jar") + override val composeCompilerPlugin = File(baseDir, "quickbuild/daemon/compose-compiler-plugin.jar") + override val androidJar = File(baseDir, "sdk/android.jar") + + /** Stands in for the app's noBackupFilesDir subtree; a temp dir in tests. */ + override val projectScratchRoot = File(baseDir, "app-private/quickbuild-scratch") + + override fun daemonEnvironment(): Map = emptyMap() +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerEdgeTest.kt new file mode 100644 index 0000000000..1cffe67635 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerEdgeTest.kt @@ -0,0 +1,76 @@ +@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + +package org.appdevforall.cotg.quickbuild.service.provision + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** + * The package-less install broadcast: some OEM installer stacks omit + * `EXTRA_PACKAGE_NAME` on session broadcasts, so a null packageName must count as + * OURS (the installer only ever commits one session at a time) instead of being + * filtered like a foreign package's broadcast. + */ +class ProxyAppInstallerEdgeTest { + private companion object { + const val PKG = "com.example.quickbuild" + } + + @TempDir lateinit var dir: File + + private lateinit var apk: File + + private class FakePackages : InstalledPackages { + var uid: Int? = null + var stamp: Long? = null + var installedApk: File? = null + + override fun uid(packageName: String): Int? = uid + + override fun lastUpdateTime(packageName: String): Long? = stamp + + override fun apkFile(packageName: String): File? = installedApk + + override fun signingCertSha256(packageName: String): String? = null + + override fun appComponentFactory(packageName: String): String? = null + } + + private val packages = FakePackages() + private val broadcasts = MutableSharedFlow(extraBufferCapacity = 16) + + private fun installer() = + ProxyAppInstaller( + packages = packages, + launchInstall = { true }, + broadcasts = broadcasts, + timeoutMillis = 10_000L, + canShowConfirmDialog = { true }, + ) + + @BeforeEach + fun setUp() { + apk = File(dir, "proxy-app.apk").apply { writeText("apk-bytes-v1") } + } + + @Test + fun `a broadcast without a package name is treated as this install's verdict`() = + runTest { + val result = async { installer().ensureInstalled(apk, PKG) } + runCurrent() + + packages.uid = 10123 + broadcasts.emit(InstallBroadcast(null, InstallBroadcast.Status.SUCCESS, null)) + + val outcome = result.await() + assertThat(outcome).isInstanceOf(InstallOutcome.Installed::class.java) + assertThat((outcome as InstallOutcome.Installed).uid).isEqualTo(10123) + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt new file mode 100644 index 0000000000..f0ca8d4601 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt @@ -0,0 +1,547 @@ +@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + +package org.appdevforall.cotg.quickbuild.service.provision + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class ProxyAppInstallerTest { + private companion object { + const val PKG = "com.example.quickbuild" + } + + @TempDir lateinit var dir: File + + private lateinit var apk: File + + /** Scripted [InstalledPackages]: a mutable picture of what is installed. */ + private class FakePackages : InstalledPackages { + var uid: Int? = null + var stamp: Long? = null + var installedApk: File? = null + + override fun uid(packageName: String): Int? = uid + + override fun lastUpdateTime(packageName: String): Long? = stamp + + override fun apkFile(packageName: String): File? = installedApk + + override fun signingCertSha256(packageName: String): String? = null + + override fun appComponentFactory(packageName: String): String? = null + } + + private val packages = FakePackages() + private val broadcasts = MutableSharedFlow(extraBufferCapacity = 16) + private val installLaunches = mutableListOf() + private var launchResult = true + + /** What the scripted launch does to the fake package state, if anything. */ + private var onLaunch: () -> Unit = {} + + /** Scripted "can the confirm dialog be launched right now" probe. */ + private var confirmDialogShowable = true + + private fun installer( + timeoutMillis: Long = 180_000L, + promptTimeoutMillis: Long = 45_000L, + ) = ProxyAppInstaller( + packages = packages, + launchInstall = { file -> + installLaunches += file + onLaunch() + launchResult + }, + broadcasts = broadcasts, + timeoutMillis = timeoutMillis, + promptTimeoutMillis = promptTimeoutMillis, + canShowConfirmDialog = { confirmDialogShowable }, + ) + + @BeforeEach + fun setUp() { + apk = File(dir, "proxy-app.apk").apply { writeText("apk-bytes-v1") } + } + + @Test + fun `installed package with identical bytes is skipped - no dialog, no reinstall`() = + runTest { + packages.uid = 10123 + packages.stamp = 111L + packages.installedApk = File(dir, "installed.apk").apply { writeText("apk-bytes-v1") } + + val outcome = installer().ensureInstalled(apk, PKG) + + assertThat(outcome).isEqualTo(InstallOutcome.Installed(10123)) + assertThat(installLaunches).isEmpty() + } + + @Test + fun `changed bytes reinstall and resolve via the success broadcast`() = + runTest { + packages.uid = 10123 + packages.stamp = 111L + packages.installedApk = File(dir, "installed.apk").apply { writeText("apk-bytes-v0") } + + val result = async { installer().ensureInstalled(apk, PKG) } + runCurrent() + assertThat(installLaunches).containsExactly(apk) + + // Non-terminal statuses are ignored; the user confirms, then success. + broadcasts.emit(InstallBroadcast(null, InstallBroadcast.Status.PENDING_USER_ACTION)) + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.OTHER)) + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.SUCCESS)) + advanceUntilIdle() + + assertThat(result.await()).isEqualTo(InstallOutcome.Installed(10123)) + } + + @Test + fun `fresh install resolves via the success broadcast and the new uid`() = + runTest { + val result = async { installer().ensureInstalled(apk, PKG) } + runCurrent() + + packages.uid = 10456 + packages.stamp = 222L + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.SUCCESS)) + advanceUntilIdle() + + assertThat(result.await()).isEqualTo(InstallOutcome.Installed(10456)) + } + + @Test + fun `failure broadcast surfaces the real installer message fast`() = + runTest { + val result = async { installer().ensureInstalled(apk, PKG) } + runCurrent() + + broadcasts.emit( + InstallBroadcast(null, InstallBroadcast.Status.FAILURE, "INSTALL_FAILED_INVALID_APK"), + ) + advanceUntilIdle() + + assertThat(result.await()) + .isEqualTo(InstallOutcome.Failed(QuickBuildMessage.Literal("INSTALL_FAILED_INVALID_APK"))) + } + + @Test + fun `broadcast for a DIFFERENT package is not ours`() = + runTest { + val result = async { installer(timeoutMillis = 10_000L).ensureInstalled(apk, PKG) } + runCurrent() + + broadcasts.emit( + InstallBroadcast("com.other.app", InstallBroadcast.Status.FAILURE, "other app failed"), + ) + advanceUntilIdle() + + // Ignored: we time out instead of misreporting the other app's failure. + val outcome = result.await() + assertThat(outcome).isInstanceOf(InstallOutcome.ConfirmationNotGiven::class.java) + assertThat((outcome as InstallOutcome.ConfirmationNotGiven).reason) + .isEqualTo(InstallOutcome.ConfirmationNotGiven.Reason.TIMED_OUT) + } + + @Test + fun `intent-fallback installs (no broadcast) complete via the lastUpdateTime poll`() = + runTest { + // MIUI's intent-based fallback never fires InstallationResultReceiver. + val result = async { installer().ensureInstalled(apk, PKG) } + runCurrent() + + packages.uid = 10789 + packages.stamp = 333L + advanceTimeBy(2_000L) + runCurrent() + + assertThat(result.await()).isEqualTo(InstallOutcome.Installed(10789)) + } + + @Test + fun `reinstall via poll needs the stamp to CHANGE - the old install does not count`() = + runTest { + packages.uid = 10123 + packages.stamp = 111L + packages.installedApk = File(dir, "installed.apk").apply { writeText("apk-bytes-v0") } + + val result = async { installer(timeoutMillis = 30_000L).ensureInstalled(apk, PKG) } + runCurrent() + advanceTimeBy(5_000L) + runCurrent() + + // Still waiting: the pre-existing install must not read as completion. + assertThat(result.isCompleted).isFalse() + + packages.stamp = 444L + advanceTimeBy(2_000L) + runCurrent() + + assertThat(result.await()).isEqualTo(InstallOutcome.Installed(10123)) + } + + @Test + fun `launch failure fails immediately`() = + runTest { + launchResult = false + + val outcome = installer().ensureInstalled(apk, PKG) + + assertThat(outcome) + .isEqualTo(InstallOutcome.Failed(QuickBuildMessage.InstallCouldNotStart)) + } + + @Test + fun `foreground timeout is ConfirmationNotGiven TIMED_OUT, never a false success`() = + runTest { + // The dialog was up the whole time (probe true) and never answered: the + // user walked away - case (c). + val result = async { installer(timeoutMillis = 10_000L).ensureInstalled(apk, PKG) } + advanceUntilIdle() + + // Distinct from Failed: nothing is broken, a retry re-prompts - callers + // (the rebaseline path) park the session for retry instead of failing hard. + val outcome = result.await() + assertThat(outcome).isInstanceOf(InstallOutcome.ConfirmationNotGiven::class.java) + assertThat((outcome as InstallOutcome.ConfirmationNotGiven).reason) + .isEqualTo(InstallOutcome.ConfirmationNotGiven.Reason.TIMED_OUT) + assertThat(outcome.message).isInstanceOf(QuickBuildMessage.ReinstallTimedOut::class.java) + } + + @Test + fun `backgrounded timeout reports the dialog was never shown - return to CoGo`() = + runTest { + // The PENDING_USER_ACTION status is deferred by Android while the host is + // backgrounded, so NOTHING arrives before the timeout. The message must not + // claim the user ignored a dialog that never existed - case (a). + confirmDialogShowable = false + val result = async { installer(timeoutMillis = 10_000L).ensureInstalled(apk, PKG) } + advanceUntilIdle() + + val outcome = result.await() + assertThat(outcome).isInstanceOf(InstallOutcome.ConfirmationNotGiven::class.java) + assertThat((outcome as InstallOutcome.ConfirmationNotGiven).reason) + .isEqualTo(InstallOutcome.ConfirmationNotGiven.Reason.DIALOG_NOT_SHOWN) + assertThat(outcome.message).isEqualTo(QuickBuildMessage.ReinstallReturnToCoGo) + } + + @Test + fun `PENDING_USER_ACTION with no showable dialog fails fast - no silent timeout wait`() = + runTest { + // Fail-fast park (defect #90): the OS asked for a confirmation, no dialog + // can be launched (host backgrounded when the deferred broadcast landed). + // The verdict must arrive NOW, not after the 180s backstop. + confirmDialogShowable = false + val result = async { installer().ensureInstalled(apk, PKG) } + runCurrent() + + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.PENDING_USER_ACTION)) + runCurrent() + + // Completed immediately - virtual time has not advanced toward the timeout. + assertThat(result.isCompleted).isTrue() + val outcome = result.await() + assertThat(outcome).isInstanceOf(InstallOutcome.ConfirmationNotGiven::class.java) + assertThat((outcome as InstallOutcome.ConfirmationNotGiven).reason) + .isEqualTo(InstallOutcome.ConfirmationNotGiven.Reason.DIALOG_NOT_SHOWN) + assertThat(outcome.message).isEqualTo(QuickBuildMessage.ReinstallReturnToCoGo) + } + + @Test + fun `PENDING_USER_ACTION with a showable dialog keeps waiting for the real verdict`() = + runTest { + // Foreground: the dialog IS up; PENDING must not park, the user may still + // confirm. + val result = async { installer().ensureInstalled(apk, PKG) } + runCurrent() + + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.PENDING_USER_ACTION)) + runCurrent() + assertThat(result.isCompleted).isFalse() + + packages.uid = 10123 + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.SUCCESS)) + advanceUntilIdle() + assertThat(result.await()).isEqualTo(InstallOutcome.Installed(10123)) + } + + @Test + fun `an aborted install is ConfirmationNotGiven DECLINED, not a hard failure`() = + runTest { + // STATUS_FAILURE_ABORTED = the user cancelled the dialog - case (b). The + // APK is fine; callers park for retry instead of surfacing a broken build. + val result = async { installer().ensureInstalled(apk, PKG) } + runCurrent() + + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.ABORTED, "user rejected")) + advanceUntilIdle() + + val outcome = result.await() + assertThat(outcome).isInstanceOf(InstallOutcome.ConfirmationNotGiven::class.java) + assertThat((outcome as InstallOutcome.ConfirmationNotGiven).reason) + .isEqualTo(InstallOutcome.ConfirmationNotGiven.Reason.DECLINED) + assertThat(outcome.message).isEqualTo(QuickBuildMessage.ReinstallDeclined) + } + + @Test + fun `the three unconfirmed-install messages are pairwise distinct`() = + runTest { + // (a) dialog never launched, (b) user cancelled, (c) user walked away - + // the user-facing text must tell them apart or the park reads as a lie. + confirmDialogShowable = false + val notShown = + async { installer().ensureInstalled(apk, PKG) } + .also { + runCurrent() + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.PENDING_USER_ACTION)) + advanceUntilIdle() + }.await() as InstallOutcome.ConfirmationNotGiven + + confirmDialogShowable = true + val declined = + async { installer().ensureInstalled(apk, PKG) } + .also { + runCurrent() + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.ABORTED)) + advanceUntilIdle() + }.await() as InstallOutcome.ConfirmationNotGiven + + val timedOut = + async { installer(timeoutMillis = 10_000L).ensureInstalled(apk, PKG) } + .also { advanceUntilIdle() } + .await() as InstallOutcome.ConfirmationNotGiven + + assertThat( + setOf(notShown.message, declined.message, timedOut.message), + ).hasSize(3) + assertThat( + setOf(notShown.reason, declined.reason, timedOut.reason), + ).hasSize(3) + } + + @Test + fun `a prompt nobody was ever shown is re-issued once inside the same budget`() = + runTest { + // Defect T12: after a CoGo process death the first install's confirm dialog can + // be lost - the OS asks, the lifecycle-bound dialog owner is not there to launch + // it, and nothing distinguishes that from a user reading the dialog. The install + // must re-prompt rather than spend the whole budget in silence. + val result = async { installer(promptTimeoutMillis = 45_000L).ensureInstalled(apk, PKG) } + runCurrent() + assertThat(installLaunches).containsExactly(apk) + + // Under the window: a user still reading the dialog is left alone. + advanceTimeBy(44_000L) + runCurrent() + assertThat(installLaunches).containsExactly(apk) + + advanceTimeBy(2_000L) + runCurrent() + assertThat(installLaunches).containsExactly(apk, apk) + + // The second prompt is answered, well inside the 180s whole-install budget. + packages.uid = 10123 + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.SUCCESS)) + advanceUntilIdle() + assertThat(result.await()).isEqualTo(InstallOutcome.Installed(10123)) + } + + @Test + fun `a declined prompt is never re-issued`() = + runTest { + // The user answered. Re-prompting would nag them with the dialog they just + // dismissed. + val result = async { installer(promptTimeoutMillis = 45_000L).ensureInstalled(apk, PKG) } + runCurrent() + + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.ABORTED, "user rejected")) + advanceUntilIdle() + + assertThat((result.await() as InstallOutcome.ConfirmationNotGiven).reason) + .isEqualTo(InstallOutcome.ConfirmationNotGiven.Reason.DECLINED) + assertThat(installLaunches).containsExactly(apk) + } + + @Test + fun `an install answered before the window is not re-prompted`() = + runTest { + val result = async { installer(promptTimeoutMillis = 45_000L).ensureInstalled(apk, PKG) } + runCurrent() + + packages.uid = 10123 + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.SUCCESS)) + advanceUntilIdle() + + assertThat(result.await()).isEqualTo(InstallOutcome.Installed(10123)) + assertThat(installLaunches).containsExactly(apk) + } + + @Test + fun `two unanswered prompts still report TIMED_OUT, not a false success`() = + runTest { + val result = + async { + installer(timeoutMillis = 100_000L, promptTimeoutMillis = 40_000L) + .ensureInstalled(apk, PKG) + } + advanceUntilIdle() + + val outcome = result.await() + assertThat(outcome).isInstanceOf(InstallOutcome.ConfirmationNotGiven::class.java) + assertThat((outcome as InstallOutcome.ConfirmationNotGiven).reason) + .isEqualTo(InstallOutcome.ConfirmationNotGiven.Reason.TIMED_OUT) + assertThat(installLaunches).containsExactly(apk, apk) + } + + @Test + fun `a backgrounded install is not re-prompted - nobody could see the second dialog either`() = + runTest { + confirmDialogShowable = false + val result = + async { + installer(timeoutMillis = 100_000L, promptTimeoutMillis = 40_000L) + .ensureInstalled(apk, PKG) + } + advanceUntilIdle() + + assertThat((result.await() as InstallOutcome.ConfirmationNotGiven).reason) + .isEqualTo(InstallOutcome.ConfirmationNotGiven.Reason.DIALOG_NOT_SHOWN) + assertThat(installLaunches).containsExactly(apk) + } + + @Test + fun `a real failure broadcast is Failed, not ConfirmationNotGiven`() = + runTest { + // Guards the distinction the retry path relies on: an actual installer + // verdict must never be presented as a retryable unconfirmed prompt. + val result = async { installer(timeoutMillis = 10_000L).ensureInstalled(apk, PKG) } + runCurrent() + + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.FAILURE, "rejected")) + advanceUntilIdle() + + assertThat(result.await()).isEqualTo(InstallOutcome.Failed(QuickBuildMessage.Literal("rejected"))) + } + + @Test + fun `unreadable installed apk is treated as a content mismatch - reinstall`() = + runTest { + packages.uid = 10123 + packages.stamp = 111L + packages.installedApk = File(dir, "does-not-exist.apk") + + val result = async { installer().ensureInstalled(apk, PKG) } + runCurrent() + + assertThat(installLaunches).containsExactly(apk) + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.SUCCESS)) + advanceUntilIdle() + assertThat(result.await()).isEqualTo(InstallOutcome.Installed(10123)) + } + + @Test + fun `success broadcast but unresolvable uid fails visibly after retries`() = + runTest { + val result = async { installer().ensureInstalled(apk, PKG) } + runCurrent() + + // Success reported, but PackageManager never resolves the package. + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.SUCCESS)) + advanceUntilIdle() + + val outcome = result.await() + assertThat(outcome).isInstanceOf(InstallOutcome.Failed::class.java) + assertThat((outcome as InstallOutcome.Failed).message) + .isInstanceOf(QuickBuildMessage.InstalledButUnresolvable::class.java) + } + + @Test + fun `uid appearing after a retry still resolves`() = + runTest { + val result = async { installer().ensureInstalled(apk, PKG) } + runCurrent() + + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.SUCCESS)) + runCurrent() + // The uid becomes visible between the broadcast and the first retry. + packages.uid = 10999 + advanceTimeBy(1_500L) + runCurrent() + + assertThat(result.await()).isEqualTo(InstallOutcome.Installed(10999)) + } + + @Test + fun `failure broadcast without a message falls back to a generic one`() = + runTest { + val result = async { installer().ensureInstalled(apk, PKG) } + runCurrent() + + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.FAILURE, message = null)) + advanceUntilIdle() + + assertThat(result.await()) + .isEqualTo(InstallOutcome.Failed(QuickBuildMessage.InstallFailed)) + } + + @Test + fun `a throwing launch is treated as could-not-start, never as a crash`() = + runTest { + onLaunch = { throw IllegalStateException("installer exploded") } + + val outcome = installer().ensureInstalled(apk, PKG) + + assertThat(outcome) + .isEqualTo(InstallOutcome.Failed(QuickBuildMessage.InstallCouldNotStart)) + } + + @Test + fun `unreadable CANDIDATE apk is a content mismatch - reinstall, not a false skip`() = + runTest { + packages.uid = 10123 + packages.stamp = 111L + packages.installedApk = File(dir, "installed.apk").apply { writeText("apk-bytes-v1") } + val missingCandidate = File(dir, "not-built.apk") + + val result = async { installer().ensureInstalled(missingCandidate, PKG) } + runCurrent() + + assertThat(installLaunches).containsExactly(missingCandidate) + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.SUCCESS)) + advanceUntilIdle() + assertThat(result.await()).isEqualTo(InstallOutcome.Installed(10123)) + } + + @Test + fun `installed package without a resolvable apk file reinstalls`() = + runTest { + packages.uid = 10123 + packages.stamp = 111L + packages.installedApk = null + + val result = async { installer().ensureInstalled(apk, PKG) } + runCurrent() + + assertThat(installLaunches).containsExactly(apk) + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.SUCCESS)) + advanceUntilIdle() + assertThat(result.await()).isEqualTo(InstallOutcome.Installed(10123)) + } + + @Test + fun `sha256 digests real content and returns null for a missing file`() { + assertThat(ProxyAppInstaller.sha256OrNull(apk)) + .isEqualTo(ProxyAppInstaller.sha256OrNull(File(dir, "copy.apk").apply { writeText("apk-bytes-v1") })) + assertThat(ProxyAppInstaller.sha256OrNull(File(dir, "missing.apk"))).isNull() + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheckTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheckTest.kt new file mode 100644 index 0000000000..1b29b57d36 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheckTest.kt @@ -0,0 +1,58 @@ +package org.appdevforall.cotg.quickbuild.service.provision + +import com.google.common.truth.Truth.assertThat +import org.appdevforall.cotg.quickbuild.domain.reload.RealIdInstall +import org.junit.jupiter.api.Test +import java.io.File + +class QuickBuildClobberCheckTest { + private val realAppId = "com.example.app" + private val quickBuildFactory = RealIdInstall.QUICK_BUILD_APP_COMPONENT_FACTORY + + /** Scripted [InstalledPackages]: only the two fields the clobber check reads matter. */ + private class FakePackages( + private val installedUid: Int?, + private val factory: String?, + ) : InstalledPackages { + override fun uid(packageName: String): Int? = installedUid + + override fun lastUpdateTime(packageName: String): Long? = null + + override fun apkFile(packageName: String): File? = null + + override fun signingCertSha256(packageName: String): String? = null + + override fun appComponentFactory(packageName: String): String? = factory + } + + private fun check( + installed: Boolean, + factory: String?, + ) = QuickBuildClobberCheck(FakePackages(if (installed) 10_123 else null, factory)) + + @Test + fun `Quick Build tap needs no confirm when the slot is empty`() { + assertThat(check(installed = false, factory = null).quickBuildNeedsConfirm(realAppId)).isFalse() + } + + @Test + fun `Quick Build tap needs no confirm over its own proxy app`() { + assertThat(check(installed = true, factory = quickBuildFactory).quickBuildNeedsConfirm(realAppId)).isFalse() + } + + @Test + fun `Quick Build tap confirms over the Standard Run build`() { + assertThat(check(installed = true, factory = null).quickBuildNeedsConfirm(realAppId)).isTrue() + } + + @Test + fun `Standard Run confirms over a Quick Build proxy app`() { + assertThat(check(installed = true, factory = quickBuildFactory).standardRunNeedsConfirm(realAppId)).isTrue() + } + + @Test + fun `Standard Run needs no confirm over a normal app or an empty slot`() { + assertThat(check(installed = true, factory = null).standardRunNeedsConfirm(realAppId)).isFalse() + assertThat(check(installed = false, factory = null).standardRunNeedsConfirm(realAppId)).isFalse() + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonControllerTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonControllerTest.kt new file mode 100644 index 0000000000..edebdd3e83 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonControllerTest.kt @@ -0,0 +1,225 @@ +package org.appdevforall.cotg.quickbuild.service.session + +import android.content.ComponentCallbacks2 +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.data.DaemonReply +import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo +import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout +import org.appdevforall.cotg.quickbuild.data.QuickBuildScratch +import org.appdevforall.cotg.quickbuild.protocol.ConfigureRequest +import org.appdevforall.cotg.quickbuild.service.FakeDaemon +import org.appdevforall.cotg.quickbuild.service.FakePaths +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** + * Seam tests for the daemon-epoch protocol, directly against + * [QuickBuildDaemonController] (the manager's 100 tests drive the same paths + * end-to-end; these pin the controller's own contract). + */ +class QuickBuildDaemonControllerTest { + @TempDir lateinit var projectRoot: File + + private val daemon = FakeDaemon() + + private fun controller() = + QuickBuildDaemonController( + daemon = daemon, + scratch = QuickBuildScratch(FakePaths(projectRoot).projectScratchRoot), + paths = FakePaths(projectRoot), + ) + + private fun proxyApp(minApi: Int = ConfigureRequest.DEFAULT_MIN_API) = + ProxyAppInfo( + proxyAppPackage = "com.example.quickbuild", + entryActivity = "com.example.MainActivity", + apk = File(projectRoot, "proxy-app.apk"), + classpath = emptyList(), + proxyClassesDir = null, + transformedManifest = null, + minApi = minApi, + ) + + private fun layout() = QuickBuildProjectLayout(projectRoot) + + @Test + fun `respawn superseded before start never starts a daemon`() = + runTest { + val controller = controller() + val epoch = controller.epochSnapshot() + controller.markIntentionalTransition() + val outcome = controller.respawn(layout(), proxyApp(), epoch) + assertThat(outcome).isEqualTo(QuickBuildDaemonController.RespawnOutcome.Superseded) + assertThat(daemon.startConfigs).isEmpty() + } + + @Test + fun `respawn superseded by exactly one transition mid-start stops its own zombie daemon`() = + runTest { + val controller = controller() + val epoch = controller.epochSnapshot() + val gate = CompletableDeferred() + daemon.startGate = gate + var outcome: QuickBuildDaemonController.RespawnOutcome? = null + val job = launch { outcome = controller.respawn(layout(), proxyApp(), epoch) } + runCurrent() // parked inside daemon.start + assertThat(daemon.startConfigs).hasSize(1) + + // EXACTLY one intentional transition: the superseding shutdown itself. The + // daemon the stale start brought up is a zombie only the respawn knows about. + controller.markIntentionalTransition() + gate.complete(Unit) + advanceUntilIdle() + job.join() + + assertThat(outcome).isEqualTo(QuickBuildDaemonController.RespawnOutcome.Superseded) + assertThat(daemon.shutdownCount).isEqualTo(1) + } + + @Test + fun `respawn superseded by two transitions discards without stopping the successor's daemon`() = + runTest { + val controller = controller() + val epoch = controller.epochSnapshot() + val gate = CompletableDeferred() + daemon.startGate = gate + var outcome: QuickBuildDaemonController.RespawnOutcome? = null + val job = launch { outcome = controller.respawn(layout(), proxyApp(), epoch) } + runCurrent() + + // Two transitions = a successor flow already started a fresh daemon; the + // stale respawn must not touch it. + controller.markIntentionalTransition() + controller.markIntentionalTransition() + gate.complete(Unit) + advanceUntilIdle() + job.join() + + assertThat(outcome).isEqualTo(QuickBuildDaemonController.RespawnOutcome.Superseded) + assertThat(daemon.shutdownCount).isEqualTo(0) + } + + @Test + fun `a respawn superseded mid-start whose start also failed has no zombie to stop`() = + runTest { + val controller = controller() + val epoch = controller.epochSnapshot() + val gate = CompletableDeferred() + daemon.startGate = gate + daemon.startReply = DaemonReply.Failed("spawn refused") + var outcome: QuickBuildDaemonController.RespawnOutcome? = null + val job = launch { outcome = controller.respawn(layout(), proxyApp(), epoch) } + runCurrent() // parked inside daemon.start + + // Exactly one transition, as in the zombie case above - but this start brought no + // daemon up, so a shutdown here would stop whatever the superseding flow owns. + controller.markIntentionalTransition() + gate.complete(Unit) + advanceUntilIdle() + job.join() + + // Superseded, not Failed: the successor flow owns the daemon lifecycle, so this + // respawn's own failure is not the session's news. + assertThat(outcome).isEqualTo(QuickBuildDaemonController.RespawnOutcome.Superseded) + assertThat(daemon.shutdownCount).isEqualTo(0) + } + + @Test + fun `respawn reports the daemon's failure message`() = + runTest { + val controller = controller() + daemon.startReply = DaemonReply.Failed("spawn refused") + val outcome = controller.respawn(layout(), proxyApp(), controller.epochSnapshot()) + assertThat(outcome) + .isEqualTo(QuickBuildDaemonController.RespawnOutcome.Failed("spawn refused")) + } + + @Test + fun `respawn names a generic failure when the reply carries no operator message`() = + runTest { + val controller = controller() + // Anything but Ok means "no daemon", and only Failed carries a message. The + // outcome still has to name something: the manager renders it as the reason the + // session went degraded. + daemon.startReply = DaemonReply.BuildFailed(emptyList()) + val outcome = controller.respawn(layout(), proxyApp(), controller.epochSnapshot()) + assertThat(outcome) + .isEqualTo(QuickBuildDaemonController.RespawnOutcome.Failed("unknown failure")) + } + + @Test + fun `onTrimMemory at UI_HIDDEN keeps the daemon warm`() = + runTest { + val controller = controller() + daemon.isRunning = true + controller.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN, buildInFlight = false) + assertThat(daemon.shutdownCount).isEqualTo(0) + assertThat(controller.epochSnapshot()).isEqualTo(0L) + } + + @Test + fun `onTrimMemory at RUNNING_LOW is a no-op`() = + runTest { + val controller = controller() + daemon.isRunning = true + controller.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW, buildInFlight = false) + // Not even deferred: a later idle retry must find nothing pending. + controller.shrinkIfPending(buildInFlight = false) + assertThat(daemon.shutdownCount).isEqualTo(0) + assertThat(controller.epochSnapshot()).isEqualTo(0L) + } + + @Test + fun `onTrimMemory at RUNNING_CRITICAL with no build in flight shuts down and bumps the epoch once`() = + runTest { + val controller = controller() + daemon.isRunning = true + controller.onTrimMemory( + ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL, + buildInFlight = false, + ) + assertThat(daemon.shutdownCount).isEqualTo(1) + assertThat(controller.epochSnapshot()).isEqualTo(1L) + } + + @Test + fun `a shrink deferred while building applies on the next non-building state`() = + runTest { + val controller = controller() + daemon.isRunning = true + controller.onTrimMemory( + ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL, + buildInFlight = true, + ) + assertThat(daemon.shutdownCount).isEqualTo(0) + + // The manager's state collector retries when the build's transition lands. + controller.shrinkIfPending(buildInFlight = false) + assertThat(daemon.shutdownCount).isEqualTo(1) + assertThat(controller.epochSnapshot()).isEqualTo(1L) + + // Consumed: a second retry must not shut down (or bump) again. + controller.shrinkIfPending(buildInFlight = false) + assertThat(daemon.shutdownCount).isEqualTo(1) + assertThat(controller.epochSnapshot()).isEqualTo(1L) + } + + @Test + fun `the daemon config takes its min API from the baseline the proxy app build dexed`() = + runTest { + // A project whose effective dex level is not the protocol default. The daemon + // must dex increments the way the seed payload was dexed, so the value has to + // travel from setup.json into the config rather than default at each end. + val controller = controller() + + controller.respawn(layout(), proxyApp(minApi = 26), controller.epochSnapshot()) + + assertThat(daemon.startConfigs.single().minApi).isEqualTo(26) + } +} From 481b6994f4a999f696dd38073a725af792d92cc4 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 22:47:48 -0700 Subject: [PATCH 02/26] =?UTF-8?q?ADFA-4128:=20qb=2007=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20parseDiagnostics=20no-throw=20contract=20+=20reques?= =?UTF-8?q?t=20bound?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Important 1: unguarded asString on diagnostic severity/message threw out of compile() on object/array values -> primitive-guarded, degrading to ERROR / "unknown error"; covered by `a non-primitive severity or message degrades instead of throwing out of compile`. Important 2: line/column asInt threw NumberFormatException on non-numeric string primitives -> runCatching like the protocol-version read, degrading to absent; covered by `a non-numeric line or column string reads as absent instead of throwing`. Important 3: the request write had no bound, so a wedged child holding a full stdin pipe parked the mutex forever and shutdown() deadlocked on the writer monitor -> write runs on the client scope under requestTimeoutMillis with destroyForcibly on expiry, and shutdown()'s EOF close moved off the teardown path; covered by `a request the daemon never reads times out instead of wedging the client` and `shutdown is not deadlocked by a write the daemon never reads`. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W --- .../quickbuild/data/DaemonProcessClient.kt | 100 +++++++-- .../data/DaemonProcessClientEdgeTest.kt | 204 ++++++++++++++++++ 2 files changed, 285 insertions(+), 19 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt index 7eee9869d2..2275bf8fb6 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt @@ -7,6 +7,8 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -26,6 +28,7 @@ import java.io.IOException import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong +import kotlin.coroutines.coroutineContext /** * Runs the quick-build daemon as a child JVM and speaks its line-delimited JSON protocol. @@ -301,8 +304,13 @@ class DaemonProcessClient( configured = false // Best effort polite stop; the protocol also treats stdin EOF as shutdown. withTimeoutOrNull(SHUTDOWN_TIMEOUT_MILLIS) { request(DaemonOps.SHUTDOWN) {} } + val out = writer withContext(Dispatchers.IO) { - runCatching { writer?.close() } + // EOF is the second polite signal, but close() blocks on the BufferedWriter + // monitor while a wedged write holds it - so it runs on [scope] rather than + // inline, and the kill path below (which closes the pipe and thereby frees any + // such writer) is always reached instead of deadlocking teardown. + scope.launch(Dispatchers.IO) { runCatching { out?.close() } } if (proc.isAlive && !proc.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)) { proc.destroyForcibly() } @@ -313,8 +321,9 @@ class DaemonProcessClient( /** * Sends one request and awaits the matching-id response. Failure of the transport - * (dead process, EOF, timeout) is a [DaemonReply.Failed]; a well-formed - * `ok=false` response is a [DaemonReply.BuildFailed] with parsed diagnostics. + * (dead process, EOF, timeout - on the response, or on a write the child never drains) + * is a [DaemonReply.Failed]; a well-formed `ok=false` response is a + * [DaemonReply.BuildFailed] with parsed diagnostics. * * @param op protocol op name, sent as `op` and echoed in timeout messages. * @param fill adds the op's own keys to the request object; `id` and `op` are already set @@ -339,15 +348,44 @@ class DaemonProcessClient( fill() } - try { - withContext(Dispatchers.IO) { - out.write(requestJson.toString()) - out.newLine() - out.flush() + // The write runs on [scope], not the caller's context: a blocking pipe write to a + // child that stopped reading stdin cannot be cancelled, only abandoned, and it must + // not park the caller (and [requestMutex]) forever while it blocks. + val writeJob = + scope.async(Dispatchers.IO) { + runCatching { + out.write(requestJson.toString()) + out.newLine() + out.flush() + } } - } catch (e: IOException) { + val writeOutcome = + try { + withTimeoutOrNull(requestTimeoutMillis) { writeJob.await() } + } catch (e: CancellationException) { + pending.remove(id) + // The caller's own cancellation propagates; a dead [scope] (client torn + // down under the caller) degrades to a reply instead. + if (coroutineContext.isActive) { + return DaemonReply.Failed("Daemon is not running", daemonDied = true) + } + throw e + } + if (writeOutcome == null) { + // The child wedged with a full stdin pipe. Only closing the pipe frees the + // blocked thread, so the daemon is killed; its death watcher then fails any + // pending requests and fires the respawn flow. pending.remove(id) - return DaemonReply.Failed("Daemon write failed: ${e.message}", daemonDied = true) + process?.destroyForcibly() + return DaemonReply.Failed( + "Daemon stopped reading requests ('$op' write timed out)", + daemonDied = true, + ) + } + val writeError = writeOutcome.exceptionOrNull() + if (writeError != null) { + pending.remove(id) + return DaemonReply.Failed("Daemon write failed: ${writeError.message}", daemonDied = true) } val response = @@ -374,9 +412,9 @@ class DaemonProcessClient( // which reports no compile counts - carries none rather than a measured zero. DaemonReply.BuildFailed( parseDiagnostics(response), - CompileStats.fromValues { key -> - response.get(key)?.takeIf { it.isJsonPrimitive }?.asLong - }, + // longOrNull, not a bare asLong: a malformed stats value degrades to + // absent instead of throwing out of the facade's no-throw contract. + CompileStats.fromValues { key -> response.longOrNull(key) }, ) } } @@ -450,24 +488,48 @@ class DaemonProcessClient( * * @param response the `ok=false` response object. * @return one [BuildDiagnostic] per well-formed entry, empty when the key is absent or not an - * array; anything but an explicit `WARNING` reads as an error and a missing message becomes - * "unknown error", so a diagnostic is never dropped for being thin. + * array; anything but an explicit `WARNING` reads as an error, a missing or non-primitive + * message becomes "unknown error", and a non-numeric line or column reads as absent, so a + * diagnostic is never dropped - and never thrown on - for being thin or oddly shaped. */ private fun parseDiagnostics(response: JsonObject): List { val array = response.get(ResponseKeys.DIAGNOSTICS) as? JsonArray ?: return emptyList() return array.mapNotNull { element -> val obj = element as? JsonObject ?: return@mapNotNull null BuildDiagnostic( + // Primitive-guarded like every other read in this file: asString on an object + // or array throws, and this facade promises never to throw for a build problem. severity = - if (obj.get(ResponseKeys.Diagnostics.SEVERITY)?.asString.equals("WARNING", ignoreCase = true)) { + if (obj + .get(ResponseKeys.Diagnostics.SEVERITY) + ?.takeIf { it.isJsonPrimitive } + ?.asString + .equals("WARNING", ignoreCase = true) + ) { BuildDiagnostic.Severity.WARNING } else { BuildDiagnostic.Severity.ERROR }, - message = obj.get(ResponseKeys.Diagnostics.MESSAGE)?.asString ?: "unknown error", + message = + obj + .get(ResponseKeys.Diagnostics.MESSAGE) + ?.takeIf { it.isJsonPrimitive } + ?.asString ?: "unknown error", file = obj.get(ResponseKeys.Diagnostics.FILE)?.takeIf { it.isJsonPrimitive }?.asString, - line = obj.get(ResponseKeys.Diagnostics.LINE)?.takeIf { it.isJsonPrimitive }?.asInt, - column = obj.get(ResponseKeys.Diagnostics.COLUMN)?.takeIf { it.isJsonPrimitive }?.asInt, + // The primitive guard alone does not stop asInt throwing NumberFormatException + // on a non-numeric string primitive ("line":"abc"); runCatching does. + line = + obj + .get(ResponseKeys.Diagnostics.LINE) + ?.takeIf { it.isJsonPrimitive } + ?.runCatching { asInt } + ?.getOrNull(), + column = + obj + .get(ResponseKeys.Diagnostics.COLUMN) + ?.takeIf { it.isJsonPrimitive } + ?.runCatching { asInt } + ?.getOrNull(), ) } } diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt index 42eb9e651b..af736e733a 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt @@ -4,9 +4,12 @@ import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic import org.appdevforall.cotg.quickbuild.protocol.ConfigureRequest import org.junit.jupiter.api.Test @@ -439,6 +442,207 @@ class DaemonProcessClientEdgeTest { assertThat(oddShapes.column).isNull() } + @Test + fun `a non-primitive severity or message degrades instead of throwing out of compile`() { + // asString on an object or array throws UnsupportedOperationException, which unguarded + // escaped parseDiagnostics straight out of compile() - the facade's no-throw contract + // says a malformed diagnostic must degrade (severity -> ERROR, message -> the default). + val diagnostics = + """[ + {"severity":{"level":"WARNING"},"message":"kept"}, + {"severity":"ERROR","message":["broken","in","parts"]} + ]""".replace(Regex("\\s+"), "") + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":false,"diagnostics":$diagnostics}' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.compile(emptyList(), emptyList()) + } + + val failure = reply as DaemonReply.BuildFailed + assertThat(failure.diagnostics).hasSize(2) + val (objectSeverity, arrayMessage) = failure.diagnostics + assertThat(objectSeverity.severity).isEqualTo(BuildDiagnostic.Severity.ERROR) + assertThat(objectSeverity.message).isEqualTo("kept") + assertThat(arrayMessage.severity).isEqualTo(BuildDiagnostic.Severity.ERROR) + assertThat(arrayMessage.message).isEqualTo("unknown error") + } + + @Test + fun `a non-numeric line or column string reads as absent instead of throwing`() { + // "abc" IS a JSON primitive, so the isJsonPrimitive guard passes and asInt throws + // NumberFormatException - the crash path the "odd shapes" test stopped short of + // (its "3" coerces cleanly). The message must still come through untouched. + val diagnostics = + """[{"severity":"ERROR","message":"bad positions","file":"A.kt","line":"abc","column":"1.5"}]""" + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":false,"diagnostics":$diagnostics}' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.compile(emptyList(), emptyList()) + } + + val failure = reply as DaemonReply.BuildFailed + assertThat(failure.diagnostics).hasSize(1) + val diagnostic = failure.diagnostics.single() + assertThat(diagnostic.message).isEqualTo("bad positions") + assertThat(diagnostic.file).isEqualTo("A.kt") + assertThat(diagnostic.line).isNull() + assertThat(diagnostic.column).isNull() + } + + @Test + fun `a malformed stats value on a build failure degrades instead of throwing`() { + // Same crash class as line/column: "slow" IS a JSON primitive, so a primitive guard + // alone lets asLong throw NumberFormatException out of the BuildFailed arm; a + // non-primitive value must degrade too. The readable key still comes through. + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":false,"diagnostics":[],"preSnapMillis":"slow","nKotlinToCompile":[3],"compileOrdinal":2}' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.compile(emptyList(), emptyList()) + } + + val failure = reply as DaemonReply.BuildFailed + val stats = failure.stats + assertThat(stats).isNotNull() + assertThat(stats!!.compileOrdinal).isEqualTo(2) + assertThat(stats.preSnapMillis).isEqualTo(0) + assertThat(stats.kotlinToCompile).isEqualTo(0) + } + + @Test + fun `a request the daemon never reads times out instead of wedging the client`() { + // After configure the script stops reading stdin, so a request larger than the pipe + // buffer blocks the write forever while it holds the request mutex - unfixed, every + // later request parks on the mutex and shutdown() deadlocks on the writer's monitor. + // The client must bound the write, kill the wedged child, and report a Failed reply. + val paths = + scriptedPaths( + """ + printf '%s' "${'$'}${'$'}" > '$tmp/daemon.pid' + read line + printf '%s\n' '${okConfigure()}' + exec sleep 120 + """.trimIndent(), + ) + // ~4MB of source paths: far past any pipe buffer, so the write reliably blocks. + val bigSources = (1..40_000).map { File(tmp, "src/deeply/nested/pkg/SourceFile$it.kt") } + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val client = DaemonProcessClient(paths, scope, requestTimeoutMillis = 500) + var reply: DaemonReply? = null + + try { + runBlocking { + check(client.start(config()) is DaemonReply.Ok) + // On [scope], not runBlocking's: unfixed, the call never returns, and a + // structured child would deadlock runBlocking itself on the way out. + val call = scope.async { client.compile(bigSources, emptyList()) } + reply = withTimeoutOrNull(30_000) { call.await() } + } + // null means the client sat on the wedged write - the hang this test pins. + assertThat(reply).isNotNull() + assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + val failed = reply as DaemonReply.Failed + assertThat(failed.message).contains("compile") + assertThat(failed.message).contains("write timed out") + assertThat(failed.daemonDied).isTrue() + } finally { + // Unwedge a stuck writer before shutdown: killing the child closes the pipe, so + // a still-blocked write (the unfixed case) throws instead of deadlocking + // writer.close() and hanging the test run in teardown. + runCatching { + val pid = File(tmp, "daemon.pid").readText().trim() + ProcessBuilder("/bin/sh", "-c", "kill -9 $pid 2>/dev/null").start().waitFor() + } + runBlocking { client.shutdown() } + scope.cancel() + } + } + + @Test + fun `shutdown is not deadlocked by a write the daemon never reads`() { + // The wedged-write scenario again, but teardown-first: with the write still blocked + // (its own timeout deliberately far off), shutdown() used to park on the + // BufferedWriter monitor in writer.close() and never reach destroyForcibly. + val paths = + scriptedPaths( + """ + printf '%s' "${'$'}${'$'}" > '$tmp/daemon.pid' + read line + printf '%s\n' '${okConfigure()}' + exec sleep 120 + """.trimIndent(), + ) + val bigSources = (1..40_000).map { File(tmp, "src/deeply/nested/pkg/SourceFile$it.kt") } + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val client = DaemonProcessClient(paths, scope, requestTimeoutMillis = 120_000) + + try { + val completed = + runBlocking { + check(client.start(config()) is DaemonReply.Ok) + scope.async { client.compile(bigSources, emptyList()) } + // Long enough for the compile write to fill the pipe and block; a + // shutdown that won the race to the writer would close it cleanly + // and pass even unfixed. + delay(2_000) + // On [scope]: unfixed, shutdown never returns, and neither a structured + // child nor withTimeoutOrNull could pull the test out of it. + val shutdownJob = scope.async { client.shutdown() } + withTimeoutOrNull(30_000) { + shutdownJob.await() + true + } + } + // null means shutdown deadlocked behind the wedged writer. + assertThat(completed).isNotNull() + assertThat(client.isRunning).isFalse() + } finally { + // Frees the blocked write in the unfixed case so teardown can finish - see the + // wedged-request test above. + runCatching { + val pid = File(tmp, "daemon.pid").readText().trim() + ProcessBuilder("/bin/sh", "-c", "kill -9 $pid 2>/dev/null").start().waitFor() + } + runBlocking { client.shutdown() } + scope.cancel() + } + } + @Test fun `a build failure without a diagnostics array reports none`() { val paths = From 681a7ee091b63e7fca8bf0cc0d195dac0a62a719 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 26 Aug 2026 23:43:45 -0700 Subject: [PATCH 03/26] ADFA-4128 (7/11): address CodeRabbit review - F1719-1 read setup.json arrays type-checked, so parse returns null instead of throwing - F1719-4 drop the dead telemetry.report import from QuickBuildProjectLayoutTest Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FstXxJ5cwWPcvmhZ9vJgJ7 --- .../cotg/quickbuild/data/ProxyAppInfo.kt | 25 +++++++++--- .../quickbuild/data/ProxyAppInfoEdgeTest.kt | 39 +++++++++++++++++++ .../data/QuickBuildProjectLayoutTest.kt | 1 - 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt index ebc20ed02b..5d4c698751 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt @@ -1,5 +1,6 @@ package org.appdevforall.cotg.quickbuild.data +import com.google.gson.JsonArray import com.google.gson.JsonObject import com.google.gson.JsonParser import org.appdevforall.cotg.quickbuild.domain.reload.ComponentInfo @@ -140,7 +141,7 @@ data class ProxyAppInfo( val classpath = obj - .getAsJsonArray("classpath") + .jsonArray("classpath") ?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString } ?.map { resolve(it, baseDir) } ?: emptyList() @@ -148,7 +149,7 @@ data class ProxyAppInfo( // hot compiles reference R, which the variant compile classpath lacks. val payloadJars = obj - .getAsJsonArray("payloadJars") + .jsonArray("payloadJars") ?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString } ?.map { resolve(it, baseDir) } ?: emptyList() @@ -175,7 +176,7 @@ data class ProxyAppInfo( ?.asInt ?: 0, components = obj - .getAsJsonArray("components") + .jsonArray("components") ?.mapNotNull { element -> (element as? JsonObject)?.let(::parseComponent) } ?: emptyList(), annotationProcessors = obj.stringArray("annotationProcessors"), @@ -192,6 +193,20 @@ data class ProxyAppInfo( ) } + /** + * The JSON array under [key], or null when the key is absent, explicitly null, or + * holds something that is not an array. + * + * Gson's `getAsJsonArray` is a raw cast: a scalar, an object or an explicit JSON null + * there throws [ClassCastException]. [parse]'s `runCatching` wraps only the initial + * document parse, so that would escape a function whose own contract is to return + * null. Every other field here is read defensively; this makes the array reads match. + * + * @param key the key to read. + * @return the array, or null rather than a throw for any other shape. + */ + private fun JsonObject.jsonArray(key: String): JsonArray? = get(key) as? JsonArray + /** * A JSON array of strings; empty when the key is absent or not an array. * @@ -200,7 +215,7 @@ data class ProxyAppInfo( * dropped rather than treated as an error. */ private fun JsonObject.stringArray(key: String): List = - getAsJsonArray(key) + jsonArray(key) ?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString } ?.filter { it.isNotBlank() } ?: emptyList() @@ -256,7 +271,7 @@ data class ProxyAppInfo( ?.asBoolean == true, supertypes = obj - .getAsJsonArray("supertypes") + .jsonArray("supertypes") ?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString } ?: emptyList(), ) diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfoEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfoEdgeTest.kt index e04051ae06..7e5316d9e9 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfoEdgeTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfoEdgeTest.kt @@ -23,6 +23,45 @@ class ProxyAppInfoEdgeTest { } """.trimIndent() + @Test + fun `an array key holding a scalar is ignored, not a crash`() { + // getAsJsonArray is a raw cast in Gson, and parse's runCatching covers only the + // initial document parse - so a hand-edited or older setup.json used to throw + // ClassCastException out of a function whose contract is to return null. + val text = json(""","classpath": "/libs/one.jar", "sourceRoots": 7""") + + val info = ProxyAppInfo.parse(text, baseDir) + + assertThat(info).isNotNull() + assertThat(info!!.classpath).isEmpty() + assertThat(info.sourceRoots).isEmpty() + } + + @Test + fun `an explicit JSON null array key is ignored, not a crash`() { + val text = json(""","components": null, "payloadJars": null""") + + val info = ProxyAppInfo.parse(text, baseDir) + + assertThat(info).isNotNull() + assertThat(info!!.components).isEmpty() + assertThat(info.classpath).isEmpty() + } + + @Test + fun `a component whose supertypes key is an object is ignored, not a crash`() { + val text = + json( + ""","components": [{"type":"activity","userClass":"com.example.Main",""" + + """"supertypes": {"0": "android.app.Activity"}}]""", + ) + + val info = ProxyAppInfo.parse(text, baseDir) + + assertThat(info).isNotNull() + assertThat(info!!.components.single().supertypes).isEmpty() + } + @Test fun `non-JSON text parses to null`() { assertThat(ProxyAppInfo.parse("not json at all", baseDir)).isNull() diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayoutTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayoutTest.kt index e5cdad9b4f..cdb7410ea8 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayoutTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayoutTest.kt @@ -1,7 +1,6 @@ package org.appdevforall.cotg.quickbuild.data import com.google.common.truth.Truth.assertThat -import org.appdevforall.cotg.quickbuild.service.telemetry.report import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.io.File From ac2ff7c7344f518304368003939d1eb5b5f22a62 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 31 Aug 2026 18:48:07 -0700 Subject: [PATCH 04/26] ADFA-4128: qb-07 review fixes - per-spawn daemon state, IO-confined installer, re-prompt guard, scratch and counter edge fixes Akash's 08-31 review of #1719, all 11 items (incl. the ProxyAppInstaller:241 CodeRabbit thread, adopted per Bryan's 08-31 decision): - DaemonProcessClient: process, writer, pending map and deliberate-stop marker become one per-spawn object; the death watcher fails its own spawn's requests FIRST, so an in-flight request no longer orphans across a replacement (it held requestMutex for its full timeout and blocked the next configure). shutdown() also clears scratchFsType. - ProxyAppInstaller: APK hashing and every PackageManager read run under an injectable ioDispatcher (session thread never blocks); the broadcast collection degrades a completed flow to Failed instead of throwing; both launchInstall guards rethrow CancellationException; a seen PENDING_USER_ACTION suppresses the 45 s re-prompt - the OS confirmed a dialog exists, so re-committing would stack a second dialog over it - and the prompt-timeout KDoc now describes that behavior. - QuickBuildScratch: an uncreatable root reports ScratchDirUnavailable instead of a false NotEnoughStorage(100, 0). - FileGenerationStore: when both renames fail, fall back to a direct write (non-atomic beats a lost counter) and delete the stale tmp. - QuickBuildDaemonController: the low-memory teardown flag is consumed only past the isRunning guard; daemonEpoch's KDoc states the restart-bumps-twice obligation. - Fakes: unresolvable "Bug-12" planning code reworded to name the behavior. Seven new tests, each verified red against the pre-fix code; quickbuild:core green both flavors. Also: plain-language pass over the comments added by these fixes Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01STCsdMzx9daNBcqMN424Ci --- .../quickbuild/data/DaemonProcessClient.kt | 116 ++++++++++-------- .../quickbuild/data/FileGenerationStore.kt | 16 ++- .../cotg/quickbuild/data/QuickBuildScratch.kt | 7 +- .../service/provision/ProxyAppInstaller.kt | 104 ++++++++++++---- .../session/QuickBuildDaemonController.kt | 9 +- .../data/DaemonProcessClientEdgeTest.kt | 49 +++++++- .../data/FileGenerationStoreTest.kt | 15 +++ .../quickbuild/data/QuickBuildScratchTest.kt | 13 ++ .../cotg/quickbuild/service/Fakes.kt | 2 +- .../provision/ProxyAppInstallerEdgeTest.kt | 5 +- .../provision/ProxyAppInstallerTest.kt | 61 ++++++++- .../session/QuickBuildDaemonControllerTest.kt | 22 ++++ 12 files changed, 329 insertions(+), 90 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt index 2275bf8fb6..d6b147dde4 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt @@ -53,19 +53,29 @@ class DaemonProcessClient( ) : QuickBuildDaemon { private val requestMutex = Mutex() private val nextId = AtomicLong(1) - private val pending = ConcurrentHashMap>() - - @Volatile private var process: Process? = null - - @Volatile private var writer: BufferedWriter? = null /** - * Deliberate-stop marker of the child [process] currently holds, replaced on every spawn - * rather than shared between them: a replaced child's watcher passes its identity guard and - * only then reads this, so a shared flag the next [start] had already cleared would report a - * death for a daemon that was deliberately replaced. + * Everything owned by one spawned child JVM, installed whole by [start] and never shared + * between spawns. The response pump and death watcher close over the instance they were + * started for, so a watcher waking up late - after the NEXT child is already spawned - + * can fail only its own child's requests and can never touch a newer session's state. */ - @Volatile private var deliberateStop = AtomicBoolean(false) + private class Spawn( + val process: Process, + val writer: BufferedWriter, + ) { + /** Requests awaiting responses on THIS child's stdout, keyed by request id. */ + val pending = ConcurrentHashMap>() + + /** + * Deliberate-stop marker for this child alone: its watcher passes its identity guard + * and only then reads this, so a shared flag the next [start] had already cleared + * would report a death for a daemon that was deliberately replaced. + */ + val deliberateStop = AtomicBoolean(false) + } + + @Volatile private var spawn: Spawn? = null @Volatile private var deathListener: ((Int) -> Unit)? = null @@ -76,7 +86,7 @@ class DaemonProcessClient( private set override val isRunning: Boolean - get() = configured && process?.isAlive == true + get() = configured && spawn?.process?.isAlive == true /** * Installs the unexpected-exit callback, replacing any previous one. @@ -98,14 +108,9 @@ class DaemonProcessClient( * the child shut down first, so a failed start never leaves a daemon behind. */ override suspend fun start(config: DaemonConfig): DaemonReply { + // Also clears scratchFsType and marks the old child's stop on its own Spawn - the + // fresh Spawn below starts with a clean marker of its own. shutdown() - // A fresh marker instead of clearing the old one: the child shutdown() just stopped - // keeps - and its watcher still reads - the instance it was marked on. - val stopFlag = AtomicBoolean(false) - this.deliberateStop = stopFlag - // Belongs to the session being replaced; a failed configure must not leave the - // previous daemon's filesystem stamped on the next session's timings. - this.scratchFsType = null val proc = try { @@ -133,9 +138,9 @@ class DaemonProcessClient( return DaemonReply.Failed("Failed to spawn daemon: ${e.message}", daemonDied = true) } - process = proc - writer = proc.outputStream.bufferedWriter() - startReaders(proc, stopFlag) + val spawn = Spawn(proc, proc.outputStream.bufferedWriter()) + this.spawn = spawn + startReaders(spawn) val configureReply = request(DaemonOps.CONFIGURE) { @@ -297,26 +302,29 @@ class DaemonProcessClient( * nothing is running; the exit it causes is marked deliberate so no death listener fires. */ override suspend fun shutdown() { - val proc = process ?: return + val spawn = this.spawn ?: return // Marked before anything can kill it, so every exit from here on is deliberate to the // watcher no matter how late it observes it. - deliberateStop.set(true) + spawn.deliberateStop.set(true) configured = false + // Belongs to the child being stopped: left in place it would stamp the previous + // daemon's filesystem on the next session's timings. + scratchFsType = null // Best effort polite stop; the protocol also treats stdin EOF as shutdown. withTimeoutOrNull(SHUTDOWN_TIMEOUT_MILLIS) { request(DaemonOps.SHUTDOWN) {} } - val out = writer + val proc = spawn.process + val out = spawn.writer withContext(Dispatchers.IO) { // EOF is the second polite signal, but close() blocks on the BufferedWriter // monitor while a wedged write holds it - so it runs on [scope] rather than // inline, and the kill path below (which closes the pipe and thereby frees any // such writer) is always reached instead of deadlocking teardown. - scope.launch(Dispatchers.IO) { runCatching { out?.close() } } + scope.launch(Dispatchers.IO) { runCatching { out.close() } } if (proc.isAlive && !proc.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)) { proc.destroyForcibly() } } - process = null - writer = null + this.spawn = null } /** @@ -336,10 +344,11 @@ class DaemonProcessClient( fill: JsonObject.() -> Unit, ): DaemonReply = requestMutex.withLock { - val out = writer ?: return DaemonReply.Failed("Daemon is not running", daemonDied = true) + val spawn = this.spawn ?: return DaemonReply.Failed("Daemon is not running", daemonDied = true) + val out = spawn.writer val id = nextId.getAndIncrement() val deferred = CompletableDeferred() - pending[id] = deferred + spawn.pending[id] = deferred val requestJson = JsonObject().apply { @@ -363,7 +372,7 @@ class DaemonProcessClient( try { withTimeoutOrNull(requestTimeoutMillis) { writeJob.await() } } catch (e: CancellationException) { - pending.remove(id) + spawn.pending.remove(id) // The caller's own cancellation propagates; a dead [scope] (client torn // down under the caller) degrades to a reply instead. if (coroutineContext.isActive) { @@ -375,8 +384,8 @@ class DaemonProcessClient( // The child wedged with a full stdin pipe. Only closing the pipe frees the // blocked thread, so the daemon is killed; its death watcher then fails any // pending requests and fires the respawn flow. - pending.remove(id) - process?.destroyForcibly() + spawn.pending.remove(id) + spawn.process.destroyForcibly() return DaemonReply.Failed( "Daemon stopped reading requests ('$op' write timed out)", daemonDied = true, @@ -384,7 +393,7 @@ class DaemonProcessClient( } val writeError = writeOutcome.exceptionOrNull() if (writeError != null) { - pending.remove(id) + spawn.pending.remove(id) return DaemonReply.Failed("Daemon write failed: ${writeError.message}", daemonDied = true) } @@ -396,11 +405,11 @@ class DaemonProcessClient( } catch (e: Exception) { null } finally { - pending.remove(id) + spawn.pending.remove(id) } ?: return DaemonReply.Failed( "Daemon did not answer '$op' (dead or timed out)", - daemonDied = process?.isAlive != true, + daemonDied = !spawn.process.isAlive, ) // Primitive-guarded like every other read: asBoolean on an object or array throws, @@ -422,15 +431,12 @@ class DaemonProcessClient( /** * Launches the stdout response pump, the stderr log drain, and the process-death watcher. * - * @param proc the freshly spawned child; all three coroutines live on [scope] and end when its - * streams close, so they need no separate cancellation. - * @param stopFlag [proc]'s own [deliberateStop] marker, closed over by the watcher so a later - * spawn's marker can never answer "was this exit deliberate?" for this child. + * @param spawn the freshly spawned child's whole per-spawn state; all three coroutines + * live on [scope], end when the child's streams close, and close over [spawn] itself - + * so however late any of them wakes, it reads and fails only its own child's state. */ - private fun startReaders( - proc: Process, - stopFlag: AtomicBoolean, - ) { + private fun startReaders(spawn: Spawn) { + val proc = spawn.process scope.launch(Dispatchers.IO) { try { proc.inputStream.bufferedReader().forEachLine { line -> @@ -445,7 +451,7 @@ class DaemonProcessClient( log.debug("daemon: {}", line) return@forEachLine } - pending.remove(id)?.complete(json) + spawn.pending.remove(id)?.complete(json) ?: log.warn("Daemon response for unknown request id {}", id) } } catch (e: IOException) { @@ -463,20 +469,22 @@ class DaemonProcessClient( } scope.launch(Dispatchers.IO) { val exitCode = runCatching { proc.waitFor() }.getOrDefault(-1) - // A child the respawn replaced dies asynchronously - destroyForcibly returns before - // the exit - so this can wake up after the NEXT child is already spawned. pending and - // configured below are shared across spawns, so touching them then would fail the new - // session's configure ("Daemon did not answer 'configure'"). - if (process !== proc) { + // This child's own requests are failed FIRST, replaced or not: a request holds + // [requestMutex] for its whole round trip, so an orphan left pending would burn + // its full timeout and hold the replacement's configure behind the mutex. The + // per-spawn map is what makes this safe however late the watcher wakes - a + // child the respawn replaced dies asynchronously (destroyForcibly returns + // before the exit), and this can run after the NEXT child is already spawned. + val abandoned = IOException("Daemon process exited (code $exitCode)") + spawn.pending.values.forEach { it.completeExceptionally(abandoned) } + spawn.pending.clear() + if (this@DaemonProcessClient.spawn !== spawn) { log.debug("Replaced quick-build daemon exited with code {}", exitCode) return@launch } - val abandoned = IOException("Daemon process exited (code $exitCode)") - pending.values.forEach { it.completeExceptionally(abandoned) } - pending.clear() configured = false - // This child's own marker, not a shared flag - see [deliberateStop]. - if (!stopFlag.get()) { + // This child's own marker, not a shared flag - see [Spawn.deliberateStop]. + if (!spawn.deliberateStop.get()) { log.error("Quick-build daemon died with exit code {}", exitCode) deathListener?.invoke(exitCode) } diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt index a27b15f6db..9a61b22830 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt @@ -39,8 +39,8 @@ class FileGenerationStore( * * @param generation the value to store; the caller guarantees it is strictly greater than * any previously saved one, since the installed proxy app keys its payloads by it. - * @throws IOException when the value could not be persisted, including the second rename - * attempt after clearing the destination; unlike [load] this is never swallowed, since + * @throws IOException when the value could not be persisted by any means - both renames + * AND the direct-write fallback failed; unlike [load] this is never swallowed, since * losing it would let a later session reuse a generation. */ override fun save(generation: Long) { @@ -52,7 +52,17 @@ class FileGenerationStore( // keeps the store correct wherever the JVM tests run. file.delete() if (!tmp.renameTo(file)) { - throw IOException("Unable to persist generation $generation to $file") + // The old value is already deleted, so a bare throw here would leave NO + // counter at all - the next load() would restart the sequence, the exact + // reuse the class exists to rule out. Non-atomic beats lost. + try { + file.writeText(generation.toString()) + } catch (e: IOException) { + throw IOException("Unable to persist generation $generation to $file", e) + } finally { + tmp.delete() + } + return } } } diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt index 7b56eb5e56..98e1f4d237 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt @@ -104,7 +104,12 @@ class QuickBuildScratch( * as a side effect, since usable space cannot be read through a directory that is not there. */ fun freeSpaceShortfall(): QuickBuildMessage? { - root.mkdirs() + // An uncreatable root must not fall through to the space read: usableSpace on a + // nonexistent path is 0, which would report "not enough storage" - the wrong + // remedy on screen - for what is a permissions or path problem. + if (!root.isDirectory && !root.mkdirs()) { + return QuickBuildMessage.ScratchDirUnavailable(root.absolutePath) + } val usable = root.usableSpace if (usable >= minFreeBytes) return null return QuickBuildMessage.NotEnoughStorage( diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt index 71877f7398..8ac7680ddb 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt @@ -1,12 +1,16 @@ package org.appdevforall.cotg.quickbuild.service.provision +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.selects.select +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage import org.slf4j.LoggerFactory @@ -141,6 +145,10 @@ sealed interface InstallOutcome { * broadcasts with real messages, and a lastUpdateTime change backstops the MIUI intent * fallback, which never broadcasts through our receiver. A broadcast with no package name is * accepted as ours, erring toward a retryable failure rather than a false success. + * + * Blocking work - the APK hashing and every [InstalledPackages] read (binder calls into + * PackageManager) - runs under [ioDispatcher], so [ensureInstalled] is safe to call from the + * session's single-threaded dispatcher (concurrency.md). */ class ProxyAppInstaller( /** Installed-package facts; every read goes through here so tests need no PackageManager. */ @@ -165,6 +173,8 @@ class ProxyAppInstaller( * always-true keeps the plain wait-for-the-user behavior for callers without a probe. */ private val canShowConfirmDialog: () -> Boolean = { true }, + /** Where the blocking work (APK hashing, PackageManager reads) runs; injectable for tests. */ + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { /** * Gets [packageName] installed from [apk], skipping the install when the bytes on @@ -180,33 +190,59 @@ class ProxyAppInstaller( apk: File, packageName: String, ): InstallOutcome { - val initialStamp = packages.lastUpdateTime(packageName) - val existingUid = packages.uid(packageName) + val initialStamp = withContext(ioDispatcher) { packages.lastUpdateTime(packageName) } + val existingUid = withContext(ioDispatcher) { packages.uid(packageName) } if (existingUid != null && isSameContent(apk, packageName)) { log.info("{} already runs these bytes; skipping reinstall", packageName) return InstallOutcome.Installed(existingUid) } return coroutineScope { + // Set when the OS reported PENDING_USER_ACTION, which means a confirm dialog + // exists; re-issuing the prompt then would stack a second dialog on it. + var pendingUserActionSeen = false // Subscribe before committing the install so a fast broadcast cannot slip // past us. PENDING_USER_ACTION is decisive too when no confirm dialog can be // launched, since nobody will ever tap. val verdict = async(start = CoroutineStart.UNDISPATCHED) { - broadcasts.first { broadcast -> - (broadcast.packageName == null || broadcast.packageName == packageName) && - ( - broadcast.isTerminal || + try { + Result.success( + broadcasts.first { broadcast -> + val ours = + broadcast.packageName == null || broadcast.packageName == packageName + if (ours && broadcast.status == InstallBroadcast.Status.PENDING_USER_ACTION) { + pendingUserActionSeen = true + } + ours && ( - broadcast.status == InstallBroadcast.Status.PENDING_USER_ACTION && - !canShowConfirmDialog() + broadcast.isTerminal || + ( + broadcast.status == InstallBroadcast.Status.PENDING_USER_ACTION && + !canShowConfirmDialog() + ) ) - ) + }, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // If the flow completes without a match, first() throws and no + // verdict can arrive this way. Captured as a failure so that + // ensureInstalled still never throws. + Result.failure(e) } } val stampChanged = async { awaitStampChange(packageName, initialStamp) } - val started = runCatching { launchInstall(apk) }.getOrDefault(false) + val started = + try { + launchInstall(apk) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + false + } if (!started) { verdict.cancel() stampChanged.cancel() @@ -215,7 +251,12 @@ class ProxyAppInstaller( val awaitVerdict: suspend () -> InstallOutcome = { select { - verdict.onAwait { broadcast -> classify(broadcast, packageName) } + verdict.onAwait { result -> + result.fold( + onSuccess = { broadcast -> classify(broadcast, packageName) }, + onFailure = { InstallOutcome.Failed(QuickBuildMessage.InstallFailed) }, + ) + } stampChanged.onAwait { resolveUid(packageName) } } } @@ -229,13 +270,23 @@ class ProxyAppInstaller( // lifecycle-bound. The deferreds are reused, so a late verdict still resolves. withTimeoutOrNull(promptTimeoutMillis) { awaitVerdict() } ?: run { - if (canShowConfirmDialog()) { + // A seen PENDING_USER_ACTION means the OS confirmed a dialog + // exists - the user is reading it, and a re-commit would put a + // second dialog over the first. Only the silent case (no status + // at all) is the lost-prompt one the re-issue repairs. + if (canShowConfirmDialog() && !pendingUserActionSeen) { log.info( "no install verdict for {} in {}ms; re-issuing the prompt", packageName, promptTimeoutMillis, ) - runCatching { launchInstall(apk) } + try { + launchInstall(apk) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // The first commit is still pending; keep waiting on it. + } } awaitVerdict() } @@ -322,7 +373,7 @@ class ProxyAppInstaller( initialStamp: Long?, ) { while (true) { - val stamp = packages.lastUpdateTime(packageName) + val stamp = withContext(ioDispatcher) { packages.lastUpdateTime(packageName) } if (stamp != null && stamp != initialStamp) return delay(DEFAULT_POLL_MILLIS) } @@ -338,7 +389,8 @@ class ProxyAppInstaller( // The uid should exist the moment the install lands; retry briefly for the // window between the success broadcast and PackageManager visibility. repeat(UID_RETRIES) { - packages.uid(packageName)?.let { return InstallOutcome.Installed(it) } + withContext(ioDispatcher) { packages.uid(packageName) } + ?.let { return InstallOutcome.Installed(it) } delay(DEFAULT_POLL_MILLIS) } return InstallOutcome.Failed(QuickBuildMessage.InstalledButUnresolvable(packageName)) @@ -352,14 +404,17 @@ class ProxyAppInstaller( * @param packageName the applicationId whose installed APK is compared against it * @return true only on a confirmed match, so an unreadable file errs toward reinstalling */ - private fun isSameContent( + private suspend fun isSameContent( apk: File, packageName: String, - ): Boolean { - val installed = packages.apkFile(packageName) ?: return false - val candidate = sha256OrNull(apk) ?: return false - return candidate == sha256OrNull(installed) - } + ): Boolean = + withContext(ioDispatcher) { + // Two full-APK hashes; off the caller's dispatcher (concurrency.md forbids + // blocking the session thread). + val installed = packages.apkFile(packageName) ?: return@withContext false + val candidate = sha256OrNull(apk) ?: return@withContext false + candidate == sha256OrNull(installed) + } companion object { private val log = LoggerFactory.getLogger("QB-ProxyInstaller") @@ -368,8 +423,11 @@ class ProxyAppInstaller( const val DEFAULT_TIMEOUT_MILLIS = 180_000L /** - * Long enough that a user reading the dialog is never re-prompted under it, short - * enough that a dialog that never appeared does not burn the whole budget in silence. + * How long a committed install may sit with no status at all before the prompt is + * re-issued. Only the silent case is repaired: a seen PENDING_USER_ACTION means a + * dialog exists and the user is reading it, so no re-issue. The silent case is a + * dialog that was never launched, e.g. a CoGo process death took the Activity that + * owned it. */ const val DEFAULT_PROMPT_TIMEOUT_MILLIS = 45_000L const val DEFAULT_POLL_MILLIS = 1_000L diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt index 12126e6676..e5110d6b38 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt @@ -33,6 +33,11 @@ internal class QuickBuildDaemonController( * Exactly one transition since a respawn captured the epoch means the superseding shutdown * itself, so a daemon the stale start brought up is a zombie the respawn must stop; more * than one means a successor flow already started a fresh daemon to leave alone. + * + * That reading holds only if every restart flow bumps TWICE - once for its shutdown and + * once for its start - while a lone shutdown bumps once. Nothing here enforces it: the + * session manager's transition paths carry the obligation, and a flow that bumps a + * different number of times silently breaks the zombie-versus-successor distinction. */ private var daemonEpoch = 0L @@ -186,8 +191,10 @@ internal class QuickBuildDaemonController( suspend fun shrinkIfPending(buildInFlight: Boolean) { if (buildInFlight) return if (!pendingLowMemoryTeardown) return - pendingLowMemoryTeardown = false + // Consumed only past the isRunning guard: clearing first would discard the request + // while the daemon is briefly down, not the silent no-op the KDoc promises. if (!daemon.isRunning) return + pendingLowMemoryTeardown = false log.info("Quick Build: tearing down the compile daemon for low memory; the next build re-warms it") markIntentionalTransition() daemon.shutdown() diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt index af736e733a..ec2195efdc 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -78,11 +79,14 @@ class DaemonProcessClientEdgeTest { /** @return paths to a fake-java script that runs [body] with `reply` already defined. */ private fun replyingPaths(body: String): ScriptedPaths = scriptedPaths("$replyOk\n$body") - /** @return the client's per-spawn deliberate-stop marker, which has no public surface. */ + /** @return the current spawn's deliberate-stop marker, which has no public surface. */ private fun DaemonProcessClient.stopMarker(): AtomicBoolean { - val field = DaemonProcessClient::class.java.getDeclaredField("deliberateStop") - field.isAccessible = true - return field.get(this) as AtomicBoolean + val spawnField = DaemonProcessClient::class.java.getDeclaredField("spawn") + spawnField.isAccessible = true + val spawn = spawnField.get(this)!! + val markerField = spawn.javaClass.getDeclaredField("deliberateStop") + markerField.isAccessible = true + return markerField.get(spawn) as AtomicBoolean } private fun okConfigure(extra: String = "") = @@ -314,6 +318,43 @@ class DaemonProcessClientEdgeTest { assertThat(failed.daemonDied).isTrue() } + @Test + fun `a replaced child's watcher frees its own in-flight request instead of orphaning it`() { + // A request holds requestMutex for its whole round trip. When the child dies while + // one is in flight and the watcher wakes only after the NEXT child is spawned, it + // must still fail its own spawn's pending requests - an orphan would burn the full + // request timeout with the mutex held, and the replacement's configure would queue + // behind it for the same time. + val paths = + replyingPaths( + """ + read line + reply "${'$'}line" + read line + sleep 60 + """.trimIndent(), + ) + + withClient(paths, timeoutMillis = 30_000) { client -> + check(client.start(config()) is DaemonReply.Ok) + coroutineScope { + val orphan = async(Dispatchers.IO) { client.compile(emptyList(), emptyList()) } + delay(500) // let the compile take the request slot + val startedAt = System.currentTimeMillis() + + val restart = client.start(config()) + val orphanReply = orphan.await() + val elapsed = System.currentTimeMillis() - startedAt + + assertThat(restart).isInstanceOf(DaemonReply.Ok::class.java) + assertThat(orphanReply).isInstanceOf(DaemonReply.Failed::class.java) + // Freed by the dead child's watcher (the restart path takes a few seconds + // of polite-shutdown budget), NOT by burning the 30 s request timeout. + assertThat(elapsed).isLessThan(15_000L) + } + } + } + @Test fun `an unexpected daemon exit fires the death listener with the exit code`() { val paths = diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt index 4fb83406f2..b54f08ee6d 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt @@ -59,6 +59,21 @@ class FileGenerationStoreTest { assertThat(FileGenerationStore(file).load()).isEqualTo(13) } + @Test + fun `a save that cannot replace the target cleans up its temp file when it throws`() { + // A non-empty directory squatting on the counter path defeats both renames AND the + // direct-write fallback; the save must still throw - the value genuinely could not + // be persisted - without leaving the .tmp orphan for the next load to trip on. + val target = File(tempDir, "generation") + target.mkdirs() + File(target, "occupant").writeText("x") + + val thrown = runCatching { FileGenerationStore(target).save(5) }.exceptionOrNull() + + assertThat(thrown).isInstanceOf(java.io.IOException::class.java) + assertThat(File(tempDir, "generation.tmp").exists()).isFalse() + } + @Test fun `forProject uses the canonical androidide state path`() { val projectRoot = File(tempDir, "project") diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchTest.kt index 7457e5c56a..c683c7d390 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchTest.kt @@ -93,6 +93,19 @@ class QuickBuildScratchTest { assertThat(scratch.freeSpaceShortfall()).isNull() } + @Test + fun `an uncreatable root reports ScratchDirUnavailable, not a storage shortfall`() { + // usableSpace on a nonexistent path is 0, so without its own guard an uncreatable + // root would read as "not enough storage" - the wrong remedy on screen - when the + // real problem is the path. + val blocker = File(root, "blocker").apply { writeText("a file, not a dir") } + val blocked = QuickBuildScratch(File(blocker, "scratch")) + + val message = blocked.freeSpaceShortfall() + + assertThat(message).isInstanceOf(QuickBuildMessage.ScratchDirUnavailable::class.java) + } + @Test fun `remove deletes the tree and tolerates a missing one`() { val project = File(projects, "MyApp") diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt index c794d9edab..acb9446837 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt @@ -18,7 +18,7 @@ class FakeDaemon : QuickBuildDaemon { val startConfigs = mutableListOf() val compileCalls = mutableListOf, List>>() - /** Removed-sources arg of each `compile`, recorded separately for Bug-12 assertions. */ + /** Removed-sources arg of each `compile`, so removed-source assertions need not unpick the changed set. */ val compileRemovedFiles = mutableListOf>() val dexCalls = mutableListOf>() val relinkCalls = mutableListOf() diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerEdgeTest.kt index 1cffe67635..bd91c832f1 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerEdgeTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerEdgeTest.kt @@ -5,6 +5,8 @@ package org.appdevforall.cotg.quickbuild.service.provision import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach @@ -46,13 +48,14 @@ class ProxyAppInstallerEdgeTest { private val packages = FakePackages() private val broadcasts = MutableSharedFlow(extraBufferCapacity = 16) - private fun installer() = + private fun TestScope.installer() = ProxyAppInstaller( packages = packages, launchInstall = { true }, broadcasts = broadcasts, timeoutMillis = 10_000L, canShowConfirmDialog = { true }, + ioDispatcher = StandardTestDispatcher(testScheduler), ) @BeforeEach diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt index f0ca8d4601..817a59a5af 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt @@ -3,8 +3,13 @@ package org.appdevforall.cotg.quickbuild.service.provision import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent @@ -52,9 +57,12 @@ class ProxyAppInstallerTest { /** Scripted "can the confirm dialog be launched right now" probe. */ private var confirmDialogShowable = true - private fun installer( + // A TestScope extension so the installer's IO hops run on the test scheduler's + // virtual time instead of a real Dispatchers.IO thread. + private fun TestScope.installer( timeoutMillis: Long = 180_000L, promptTimeoutMillis: Long = 45_000L, + broadcastFlow: Flow = broadcasts, ) = ProxyAppInstaller( packages = packages, launchInstall = { file -> @@ -62,10 +70,11 @@ class ProxyAppInstallerTest { onLaunch() launchResult }, - broadcasts = broadcasts, + broadcasts = broadcastFlow, timeoutMillis = timeoutMillis, promptTimeoutMillis = promptTimeoutMillis, canShowConfirmDialog = { confirmDialogShowable }, + ioDispatcher = StandardTestDispatcher(testScheduler), ) @BeforeEach @@ -538,6 +547,54 @@ class ProxyAppInstallerTest { assertThat(result.await()).isEqualTo(InstallOutcome.Installed(10123)) } + @Test + fun `a confirmed dialog suppresses the re-prompt - the user is reading it`() = + runTest { + // PENDING_USER_ACTION with a showable dialog is the OS saying the confirm + // dialog exists. The prompt-timeout re-issue exists for the silent lost-prompt + // case only; re-committing here would stack a second dialog over the one the + // user is reading. + val result = async { installer(promptTimeoutMillis = 45_000L).ensureInstalled(apk, PKG) } + runCurrent() + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.PENDING_USER_ACTION)) + runCurrent() + + advanceTimeBy(46_000L) + runCurrent() + assertThat(installLaunches).containsExactly(apk) + + // The slow reader eventually confirms; the one committed install lands. + packages.uid = 10123 + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.SUCCESS)) + advanceUntilIdle() + assertThat(result.await()).isEqualTo(InstallOutcome.Installed(10123)) + } + + @Test + fun `a completed broadcast flow degrades to Failed instead of throwing`() = + runTest { + // ensureInstalled promises never to throw; first() on a flow that completes + // without a match throws NoSuchElementException, which would kill the + // provisioning scope instead of failing the one install. + val outcome = installer(broadcastFlow = emptyFlow()).ensureInstalled(apk, PKG) + + assertThat(outcome).isInstanceOf(InstallOutcome.Failed::class.java) + } + + @Test + fun `cancellation during the install launch is not swallowed into a Failed outcome`() = + runTest { + // A CancellationException from the launch path is the session going away, not + // an install failure; reporting InstallCouldNotStart would have the caller + // keep working in a cancelled scope. + onLaunch = { throw CancellationException("session torn down") } + + val thrown = + runCatching { installer().ensureInstalled(apk, PKG) }.exceptionOrNull() + + assertThat(thrown).isInstanceOf(CancellationException::class.java) + } + @Test fun `sha256 digests real content and returns null for a missing file`() { assertThat(ProxyAppInstaller.sha256OrNull(apk)) diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonControllerTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonControllerTest.kt index edebdd3e83..c579e4d568 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonControllerTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonControllerTest.kt @@ -210,6 +210,28 @@ class QuickBuildDaemonControllerTest { assertThat(controller.epochSnapshot()).isEqualTo(1L) } + @Test + fun `a shrink retried while the daemon is briefly down keeps the request pending`() = + runTest { + val controller = controller() + daemon.isRunning = true + controller.onTrimMemory( + ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL, + buildInFlight = true, + ) + + // The retry lands while the daemon happens to be down (say, mid-restart). The + // KDoc promises a silent no-op here - the request must survive, not be + // consumed on the way to the guard. + daemon.isRunning = false + controller.shrinkIfPending(buildInFlight = false) + assertThat(daemon.shutdownCount).isEqualTo(0) + + daemon.isRunning = true + controller.shrinkIfPending(buildInFlight = false) + assertThat(daemon.shutdownCount).isEqualTo(1) + } + @Test fun `the daemon config takes its min API from the baseline the proxy app build dexed`() = runTest { From 494982a311bde6eee358e530f04ec1ebd15f37d8 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 2 Sep 2026 17:27:12 -0700 Subject: [PATCH 05/26] ADFA-4128: 0902 review round on quickbuild:core provisioning Akash's 2 September round on the provisioning slice and the daemon client. - The daemon's death watcher drains the stdout pump before failing pending requests. A child that writes its reply and exits in the same breath had that reply still in the pipe when waitFor returned, so start() reported a failure from a daemon that had answered. Bounded at 2s so a wedged pump cannot hold the watcher. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3914496369 - start() takes a mutex: two overlapping starts each spawned a child JVM and only the second was tracked, orphaning the first. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3914496380 - The shutdown kill path runs NonCancellable, so a cancelled teardown cannot leave the child alive with the client believing it stopped. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3914496394 - A rejected configure carries the daemon's own first diagnostic instead of a bare "Daemon rejected configuration". https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3914496437 - requestTimeoutMillis's KDoc says it is applied per phase, so a caller can read the worst case as up to twice it. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3914496427 - A low-memory teardown the daemon never came back for expires after 60s instead of being held for the rest of the session and fired at an unrelated later daemon. The controller takes an injectable clock for the test. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3914504652 - QuickBuildClobberCheck does its PackageManager reads on an injected IO dispatcher; both entry points are suspend now. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3914496402 - QuickBuildProjectLayout's KDoc drops the "pure File arithmetic" claim: allSources and moduleDirs walk the tree and belong off the main thread. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3914496414 - ProxyAppInstaller's classify returns a Verdict rather than suspending inside a select clause, so uid resolution happens after the await instead of under it, and a plain SUCCESS no longer times out inside resolveUid and re-prompts. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3914503981 - The provision README no longer links a file that lands in a later PR. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3914504375 - The scratch-filesystem test asserts the field is cleared on shutdown, which nothing pinned. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3914504961 - Fakes.kt loses two inline coroutine FQNs and FakePaths gains a KDoc. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3914496444 The pump-drain fix is not pinned by a regression test. With the drain line deleted, DaemonProcessClientEdgeTest passed six of six isolated runs, so the race does not reproduce on this machine; the fix stands on the ordering argument above, not on a test that goes red without it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../quickbuild/data/DaemonProcessClient.kt | 95 ++++++++++++++----- .../data/QuickBuildProjectLayout.kt | 9 +- .../service/provision/ProxyAppInstaller.kt | 77 ++++++++++----- .../provision/QuickBuildClobberCheck.kt | 30 ++++-- .../quickbuild/service/provision/README.md | 2 +- .../session/QuickBuildDaemonController.kt | 36 ++++++- .../data/DaemonProcessClientTest.kt | 4 + .../cotg/quickbuild/service/Fakes.kt | 6 +- .../provision/QuickBuildClobberCheckTest.kt | 46 +++++---- 9 files changed, 223 insertions(+), 82 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt index d6b147dde4..c53ba069ab 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt @@ -7,6 +7,8 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -43,8 +45,9 @@ import kotlin.coroutines.coroutineContext * parent becomes the child's cwd), and the child's environment. * @property scope coroutine scope the stdout pump, stderr drain, and death watcher run in; * cancelling it abandons those readers but does not kill the child, which [shutdown] does. - * @property requestTimeoutMillis per-request ceiling in milliseconds, past which the call yields - * a [DaemonReply.Failed] rather than an exception and releases the request slot. + * @property requestTimeoutMillis ceiling in milliseconds applied PER PHASE - once to the write + * and again to the response wait - so one call can hold [requestMutex] for up to twice it + * before yielding a [DaemonReply.Failed] rather than an exception and releasing the slot. */ class DaemonProcessClient( private val paths: QuickBuildPaths, @@ -52,6 +55,14 @@ class DaemonProcessClient( private val requestTimeoutMillis: Long = DEFAULT_REQUEST_TIMEOUT_MILLIS, ) : QuickBuildDaemon { private val requestMutex = Mutex() + + /** + * Serializes [start], because it shuts down whatever is running and installs a new [spawn]. + * + * Two overlapping starts otherwise both spawn a child and the loser's is never assigned to + * [spawn], so nothing ever shuts it down and it holds a JVM heap for the app's life. + */ + private val startMutex = Mutex() private val nextId = AtomicLong(1) /** @@ -73,6 +84,12 @@ class DaemonProcessClient( * would report a death for a daemon that was deliberately replaced. */ val deliberateStop = AtomicBoolean(false) + + /** + * This child's stdout pump, so the death watcher can drain it before failing pending + * requests. Assigned by [startReaders] before the watcher can observe an exit. + */ + @Volatile var pump: Job? = null } @Volatile private var spawn: Spawn? = null @@ -107,7 +124,16 @@ class DaemonProcessClient( * [DaemonReply.Failed] (spawn failure, protocol mismatch, or a rejected configuration) with * the child shut down first, so a failed start never leaves a daemon behind. */ - override suspend fun start(config: DaemonConfig): DaemonReply { + override suspend fun start(config: DaemonConfig): DaemonReply = + startMutex.withLock { startLocked(config) } + + /** + * The [start] body, run under [startMutex]. + * + * @param config the session-fixed settings sent in the `configure` request. + * @return what [start] returns. + */ + private suspend fun startLocked(config: DaemonConfig): DaemonReply { // Also clears scratchFsType and marks the old child's stop on its own Spawn - the // fresh Spawn below starts with a clean marker of its own. shutdown() @@ -185,7 +211,13 @@ class DaemonProcessClient( } is DaemonReply.BuildFailed -> { - DaemonReply.Failed("Daemon rejected configuration", daemonDied = false) + // The diagnostics say WHY it was rejected and nothing else reads them on this + // path, so the first one travels in the message rather than being dropped. + val why = configureReply.diagnostics.firstOrNull()?.message + DaemonReply.Failed( + "Daemon rejected configuration" + (why?.let { ": $it" } ?: ""), + daemonDied = false, + ) } is DaemonReply.Failed -> { @@ -314,7 +346,9 @@ class DaemonProcessClient( withTimeoutOrNull(SHUTDOWN_TIMEOUT_MILLIS) { request(DaemonOps.SHUTDOWN) {} } val proc = spawn.process val out = spawn.writer - withContext(Dispatchers.IO) { + // NonCancellable because this is the only thing that kills the child: a teardown + // cancelled at the waitFor would leave a live JVM nothing else ever stops. + withContext(Dispatchers.IO + NonCancellable) { // EOF is the second polite signal, but close() blocks on the BufferedWriter // monitor while a wedged write holds it - so it runs on [scope] rather than // inline, and the kill path below (which closes the pipe and thereby frees any @@ -437,27 +471,29 @@ class DaemonProcessClient( */ private fun startReaders(spawn: Spawn) { val proc = spawn.process - scope.launch(Dispatchers.IO) { - try { - proc.inputStream.bufferedReader().forEachLine { line -> - val json = - runCatching { JsonParser.parseString(line).asJsonObject }.getOrNull() - // The id read needs the same guard as the parse: a non-numeric or nested - // id would throw out of forEachLine, killing this pump for the rest of - // the session. Every later request would then burn its full timeout and - // still see the process alive, so nothing would ever respawn the daemon. - val id = json?.get(ResponseKeys.ID)?.runCatching { asLong }?.getOrNull() - if (id == null) { - log.debug("daemon: {}", line) - return@forEachLine + val pump = + scope.launch(Dispatchers.IO) { + try { + proc.inputStream.bufferedReader().forEachLine { line -> + val json = + runCatching { JsonParser.parseString(line).asJsonObject }.getOrNull() + // The id read needs the same guard as the parse: a non-numeric or nested + // id would throw out of forEachLine, killing this pump for the rest of + // the session. Every later request would then burn its full timeout and + // still see the process alive, so nothing would ever respawn the daemon. + val id = json?.get(ResponseKeys.ID)?.runCatching { asLong }?.getOrNull() + if (id == null) { + log.debug("daemon: {}", line) + return@forEachLine + } + spawn.pending.remove(id)?.complete(json) + ?: log.warn("Daemon response for unknown request id {}", id) } - spawn.pending.remove(id)?.complete(json) - ?: log.warn("Daemon response for unknown request id {}", id) + } catch (e: IOException) { + log.debug("Daemon stdout closed: {}", e.message) } - } catch (e: IOException) { - log.debug("Daemon stdout closed: {}", e.message) } - } + spawn.pump = pump scope.launch(Dispatchers.IO) { try { proc.errorStream.bufferedReader().forEachLine { line -> @@ -469,6 +505,12 @@ class DaemonProcessClient( } scope.launch(Dispatchers.IO) { val exitCode = runCatching { proc.waitFor() }.getOrDefault(-1) + // Drain first. The child can write its reply and exit in the same breath, and the + // bytes are still in the pipe when waitFor returns - failing pending here discarded + // a reply that was already written, which the pump then reported as a response for + // an unknown id. The pump ends at stdout EOF, which the exit guarantees; the bound + // is only there so a pump wedged on an inherited fd cannot strand the death report. + withTimeoutOrNull(PUMP_DRAIN_TIMEOUT_MILLIS) { spawn.pump?.join() } // This child's own requests are failed FIRST, replaced or not: a request holds // [requestMutex] for its whole round trip, so an orphan left pending would burn // its full timeout and hold the replacement's configure behind the mutex. The @@ -625,5 +667,12 @@ class DaemonProcessClient( const val DEFAULT_REQUEST_TIMEOUT_MILLIS = 300_000L private const val SHUTDOWN_TIMEOUT_MILLIS = 3_000L + + /** + * How long the death watcher waits for the stdout pump to reach EOF before it gives up + * and fails the pending requests anyway. The child's exit closes stdout, so the pump ends + * on its own; this only bounds the pathological case where something else holds the fd. + */ + private const val PUMP_DRAIN_TIMEOUT_MILLIS = 2_000L } } diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayout.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayout.kt index f7758b94ac..21e844a493 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayout.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayout.kt @@ -6,9 +6,12 @@ import java.io.File * What the quick path needs to know about the user project's shape. * * Convention-based, for the standard single-app-module project the templates emit: sources in - * `src/main/{java,kotlin}`, resources in `src/main/res`, assets in `src/main/assets`. Pure - * `File` arithmetic over those conventions, so tests build one over a temp dir rather than - * faking it. + * `src/main/{java,kotlin}`, resources in `src/main/res`, assets in `src/main/assets`. Built out + * of those conventions rather than out of a model, so tests build one over a temp dir rather + * than faking it. + * + * Not all of it is arithmetic: [allSources] and [moduleDirs] walk the tree, so they are disk + * reads and belong off the main thread. The path accessors are arithmetic and cost nothing. * * @property projectRoot the user project's root directory, which the watched gradle config * files and the module scan hang off. diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt index 8ac7680ddb..954d6509e5 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt @@ -249,15 +249,15 @@ class ProxyAppInstaller( return@coroutineScope InstallOutcome.Failed(QuickBuildMessage.InstallCouldNotStart) } - val awaitVerdict: suspend () -> InstallOutcome = { - select { + val awaitVerdict: suspend () -> Verdict = { + select { verdict.onAwait { result -> result.fold( - onSuccess = { broadcast -> classify(broadcast, packageName) }, - onFailure = { InstallOutcome.Failed(QuickBuildMessage.InstallFailed) }, + onSuccess = { broadcast -> classify(broadcast) }, + onFailure = { Verdict.Settled(InstallOutcome.Failed(QuickBuildMessage.InstallFailed)) }, ) } - stampChanged.onAwait { resolveUid(packageName) } + stampChanged.onAwait { Verdict.InstalledPendingUid } } } val outcome = @@ -293,47 +293,78 @@ class ProxyAppInstaller( } verdict.cancel() stampChanged.cancel() - outcome ?: confirmationNotGivenAtTimeout() + // The uid is resolved here, OUTSIDE both timeout windows. It retries over + // PackageManager's post-install lag, and inside the prompt budget a plain SUCCESS + // arriving late had that retry cut short - which read as "no verdict yet" and + // re-prompted the user for an install that had already succeeded. + when (outcome) { + null -> confirmationNotGivenAtTimeout() + Verdict.InstalledPendingUid -> resolveUid(packageName) + is Verdict.Settled -> outcome.outcome + } } } + /** + * What one wait for an install verdict produced, before the uid read. + * + * Separate from [InstallOutcome] so the uid read can happen after the timeouts: the read + * retries over PackageManager lag and must not be charged to the prompt budget. + */ + private sealed interface Verdict { + /** The install landed; only the uid is still to be read. */ + data object InstalledPendingUid : Verdict + + /** + * A verdict that needs nothing further. + * + * @property outcome what [ensureInstalled] returns. + */ + data class Settled( + val outcome: InstallOutcome, + ) : Verdict + } + /** * Turns the broadcast that settled an install into its outcome. * * @param broadcast the terminal broadcast, or a PENDING_USER_ACTION no dialog can answer - * @param packageName the applicationId being installed, needed to read back the uid - * @return the outcome this broadcast means + * @return what this broadcast means; SUCCESS still owes a uid read, which the caller does + * outside the timeout windows */ - private suspend fun classify( - broadcast: InstallBroadcast, - packageName: String, - ): InstallOutcome = + private fun classify(broadcast: InstallBroadcast): Verdict = when (broadcast.status) { InstallBroadcast.Status.SUCCESS -> { - resolveUid(packageName) + Verdict.InstalledPendingUid } InstallBroadcast.Status.PENDING_USER_ACTION -> { // The OS asked for a confirmation no dialog can deliver right now, so park // immediately instead of waiting out the timeout. - InstallOutcome.ConfirmationNotGiven( - QuickBuildMessage.ReinstallReturnToCoGo, - InstallOutcome.ConfirmationNotGiven.Reason.DIALOG_NOT_SHOWN, + Verdict.Settled( + InstallOutcome.ConfirmationNotGiven( + QuickBuildMessage.ReinstallReturnToCoGo, + InstallOutcome.ConfirmationNotGiven.Reason.DIALOG_NOT_SHOWN, + ), ) } InstallBroadcast.Status.ABORTED -> { - InstallOutcome.ConfirmationNotGiven( - QuickBuildMessage.ReinstallDeclined, - InstallOutcome.ConfirmationNotGiven.Reason.DECLINED, + Verdict.Settled( + InstallOutcome.ConfirmationNotGiven( + QuickBuildMessage.ReinstallDeclined, + InstallOutcome.ConfirmationNotGiven.Reason.DECLINED, + ), ) } else -> { - InstallOutcome.Failed( - broadcast.message - ?.let(QuickBuildMessage::Literal) - ?: QuickBuildMessage.InstallFailed, + Verdict.Settled( + InstallOutcome.Failed( + broadcast.message + ?.let(QuickBuildMessage::Literal) + ?: QuickBuildMessage.InstallFailed, + ), ) } } diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt index 69383c7ae9..75da838fa6 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt @@ -1,5 +1,8 @@ package org.appdevforall.cotg.quickbuild.service.provision +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.appdevforall.cotg.quickbuild.domain.reload.RealIdInstall /** @@ -11,10 +14,15 @@ import org.appdevforall.cotg.quickbuild.domain.reload.RealIdInstall * occupies the slot; [RealIdInstall] holds the rules. Stateless, so an install or uninstall * outside CoGo cannot leave it stale. * + * Both reads reach PackageManager, which is binder I/O, so both are suspending and hop to + * [ioDispatcher]. The live callers are tap handlers on the main thread. + * * @property packages read on every call, never cached, which is what keeps this stateless + * @property ioDispatcher where the PackageManager reads run; injected so tests stay direct */ class QuickBuildClobberCheck( private val packages: InstalledPackages, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { /** * True when a Quick Build tap for [realApplicationId] would clobber a different build. @@ -23,11 +31,13 @@ class QuickBuildClobberCheck( * @return true only when the slot holds something a Quick Build would overwrite; an * empty slot needs no confirmation */ - fun quickBuildNeedsConfirm(realApplicationId: String): Boolean = - RealIdInstall.quickBuildNeedsClobberConfirm( - realAppInstalled = packages.uid(realApplicationId) != null, - installedFactory = packages.appComponentFactory(realApplicationId), - ) + suspend fun quickBuildNeedsConfirm(realApplicationId: String): Boolean = + withContext(ioDispatcher) { + RealIdInstall.quickBuildNeedsClobberConfirm( + realAppInstalled = packages.uid(realApplicationId) != null, + installedFactory = packages.appComponentFactory(realApplicationId), + ) + } /** * True when a Standard Run for [realApplicationId] would clobber a Quick Build proxy app. @@ -35,8 +45,10 @@ class QuickBuildClobberCheck( * @param realApplicationId the project's own applicationId, the slot both builds share * @return true only when the installed app carries the Quick Build runtime factory */ - fun standardRunNeedsConfirm(realApplicationId: String): Boolean = - RealIdInstall.standardRunNeedsClobberConfirm( - packages.appComponentFactory(realApplicationId), - ) + suspend fun standardRunNeedsConfirm(realApplicationId: String): Boolean = + withContext(ioDispatcher) { + RealIdInstall.standardRunNeedsClobberConfirm( + packages.appComponentFactory(realApplicationId), + ) + } } diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/README.md b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/README.md index 98c33319c9..dac6cec1a6 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/README.md +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/README.md @@ -5,7 +5,7 @@ This folder holds the provisioning side of the service layer: building the Gradl | File | Purpose | | --- | --- | | [`QuickBuildProvisioner.kt`](QuickBuildProvisioner.kt) | Interface: the door to Gradle (provision, rebuild, prebuild, cancel), plus the `ProvisionOutcome` / `ProxyAppRebuildOutcome` result types. | -| [`ProxyAppBuildRunner.kt`](ProxyAppBuildRunner.kt) | Runs a provision or rebuild as a stateless verdict - disk guard, build, scratch tree, deploy session, daemon start - returning a result the manager dispatches on. | +| `ProxyAppBuildRunner.kt` (lands with the session-orchestration PR) | Runs a provision or rebuild as a stateless verdict - disk guard, build, scratch tree, deploy session, daemon start - returning a result the manager dispatches on. | | [`ProxyAppInstaller.kt`](ProxyAppInstaller.kt) | Installs the proxy app via CoGo's install pathway, skips when APK bytes already match, and waits on PackageInstaller broadcasts for a real verdict. | | [`ProxyAppLauncher.kt`](ProxyAppLauncher.kt) | Interface: relaunches the proxy app so a fresh process boots on the newest persisted generation. | | [`QuickBuildClobberCheck.kt`](QuickBuildClobberCheck.kt) | Stateless check of whether a Quick Build or Standard Run tap would clobber the other build in the shared install slot, keyed on the installed component factory. | diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt index e5110d6b38..c382dec564 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt @@ -25,6 +25,8 @@ internal class QuickBuildDaemonController( private val scratch: QuickBuildScratch, /** Locations of the bundled aapt2, d8, android.jar, and Compose compiler plugin. */ private val paths: QuickBuildPaths, + /** Wall clock for the pending-teardown deadline; injected so a test need not sleep. */ + private val now: () -> Long = System::currentTimeMillis, ) { /** * Count of intentional daemon transitions, used to detect that a respawn was @@ -44,6 +46,15 @@ internal class QuickBuildDaemonController( /** Set only on the session dispatcher; a build in flight defers the teardown here. */ private var pendingLowMemoryTeardown = false + /** + * When the pending teardown was asked for, so it can expire. + * + * Not consuming the request while the daemon is briefly down is what stops a respawn from + * swallowing it - but with nothing to expire it, a request made against a daemon that never + * comes back is held for the rest of the session and torn down on some unrelated later build. + */ + private var pendingLowMemoryTeardownAt = 0L + /** * Records an intentional daemon lifecycle transition. * @@ -176,6 +187,7 @@ internal class QuickBuildDaemonController( return } pendingLowMemoryTeardown = true + pendingLowMemoryTeardownAt = now() shrinkIfPending(buildInFlight) } @@ -183,8 +195,8 @@ internal class QuickBuildDaemonController( * Carries out a deferred low-memory teardown once no build is in flight. * * A build in flight leaves the pending flag set for the manager's state collector to - * retry. Idempotent: with no pending request, or a daemon already down, this is a - * silent no-op. + * retry. Idempotent: with no pending request this is a silent no-op, and a daemon that is + * down keeps the request only until [PENDING_TEARDOWN_DEADLINE_MILLIS] has passed. * * @param buildInFlight true to leave the request pending for a later call */ @@ -192,8 +204,16 @@ internal class QuickBuildDaemonController( if (buildInFlight) return if (!pendingLowMemoryTeardown) return // Consumed only past the isRunning guard: clearing first would discard the request - // while the daemon is briefly down, not the silent no-op the KDoc promises. - if (!daemon.isRunning) return + // while the daemon is briefly down, not the silent no-op the KDoc promises. Held + // requests do expire, though - past the deadline the memory pressure that asked for + // this is old news, and acting on it would tear down a daemon the user is using. + if (!daemon.isRunning) { + if (now() - pendingLowMemoryTeardownAt >= PENDING_TEARDOWN_DEADLINE_MILLIS) { + log.debug("Quick Build: dropping a low-memory teardown the daemon never came back for") + pendingLowMemoryTeardown = false + } + return + } pendingLowMemoryTeardown = false log.info("Quick Build: tearing down the compile daemon for low memory; the next build re-warms it") markIntentionalTransition() @@ -231,6 +251,14 @@ internal class QuickBuildDaemonController( ) private companion object { + /** + * How long a deferred low-memory teardown survives a daemon that is not running. + * + * Long enough to outlast a respawn, short enough that the request cannot resurface on + * a build minutes later, when the pressure that asked for it is gone. + */ + const val PENDING_TEARDOWN_DEADLINE_MILLIS = 60_000L + private val log = LoggerFactory.getLogger("QB-DaemonController") } } diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientTest.kt index 9d3f9bcf50..e1c5e8a4a6 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientTest.kt @@ -206,6 +206,10 @@ class DaemonProcessClientTest { try { runBlocking { client.start(config()) } assertThat(client.scratchFsType).isEqualTo("fuse") + // Belongs to the child being stopped: left in place it would stamp this daemon's + // filesystem on the next session's timings. + runBlocking { client.shutdown() } + assertThat(client.scratchFsType).isNull() } finally { runBlocking { client.shutdown() } scope.cancel() diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt index acb9446837..07508f0272 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt @@ -1,5 +1,6 @@ package org.appdevforall.cotg.quickbuild.service +import kotlinx.coroutines.CompletableDeferred import org.appdevforall.cotg.quickbuild.data.CompileOutput import org.appdevforall.cotg.quickbuild.data.DaemonConfig import org.appdevforall.cotg.quickbuild.data.DaemonReply @@ -43,7 +44,7 @@ class FakeDaemon : QuickBuildDaemon { * gate - later starts pass through. Lets a race test hold a respawn mid-start while * something else (a rebaseline, a teardown) takes the daemon down. */ - var startGate: kotlinx.coroutines.CompletableDeferred? = null + var startGate: CompletableDeferred? = null /** * Makes a gated [start] finish its wait even after the calling coroutine is cancelled. @@ -56,7 +57,7 @@ class FakeDaemon : QuickBuildDaemon { * When set, the NEXT [shutdown] parks here, consuming the gate - later shutdowns pass * through. Lets a test hold a teardown's daemon stop open while a new session goes live. */ - var shutdownGate: kotlinx.coroutines.CompletableDeferred? = null + var shutdownGate: CompletableDeferred? = null /** * Runs inside [start], after the reply is decided but before it is returned. The hook for a @@ -192,6 +193,7 @@ class MemoryGenerationStore : GenerationStore { } } +/** In-memory [QuickBuildPaths] over one temp dir, so a test needs no staged toolchain. */ class FakePaths( baseDir: File, ) : QuickBuildPaths { diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheckTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheckTest.kt index 1b29b57d36..c2695da77c 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheckTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheckTest.kt @@ -1,6 +1,8 @@ package org.appdevforall.cotg.quickbuild.service.provision import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest import org.appdevforall.cotg.quickbuild.domain.reload.RealIdInstall import org.junit.jupiter.api.Test import java.io.File @@ -28,31 +30,41 @@ class QuickBuildClobberCheckTest { private fun check( installed: Boolean, factory: String?, - ) = QuickBuildClobberCheck(FakePackages(if (installed) 10_123 else null, factory)) + ) = QuickBuildClobberCheck( + FakePackages(if (installed) 10_123 else null, factory), + Dispatchers.Unconfined, + ) @Test - fun `Quick Build tap needs no confirm when the slot is empty`() { - assertThat(check(installed = false, factory = null).quickBuildNeedsConfirm(realAppId)).isFalse() - } + fun `Quick Build tap needs no confirm when the slot is empty`() = + runTest { + assertThat(check(installed = false, factory = null).quickBuildNeedsConfirm(realAppId)).isFalse() + } @Test - fun `Quick Build tap needs no confirm over its own proxy app`() { - assertThat(check(installed = true, factory = quickBuildFactory).quickBuildNeedsConfirm(realAppId)).isFalse() - } + fun `Quick Build tap needs no confirm over its own proxy app`() = + runTest { + assertThat(check(installed = true, factory = quickBuildFactory).quickBuildNeedsConfirm(realAppId)) + .isFalse() + } @Test - fun `Quick Build tap confirms over the Standard Run build`() { - assertThat(check(installed = true, factory = null).quickBuildNeedsConfirm(realAppId)).isTrue() - } + fun `Quick Build tap confirms over the Standard Run build`() = + runTest { + assertThat(check(installed = true, factory = null).quickBuildNeedsConfirm(realAppId)).isTrue() + } @Test - fun `Standard Run confirms over a Quick Build proxy app`() { - assertThat(check(installed = true, factory = quickBuildFactory).standardRunNeedsConfirm(realAppId)).isTrue() - } + fun `Standard Run confirms over a Quick Build proxy app`() = + runTest { + assertThat(check(installed = true, factory = quickBuildFactory).standardRunNeedsConfirm(realAppId)) + .isTrue() + } @Test - fun `Standard Run needs no confirm over a normal app or an empty slot`() { - assertThat(check(installed = true, factory = null).standardRunNeedsConfirm(realAppId)).isFalse() - assertThat(check(installed = false, factory = null).standardRunNeedsConfirm(realAppId)).isFalse() - } + fun `Standard Run needs no confirm over a normal app or an empty slot`() = + runTest { + assertThat(check(installed = true, factory = null).standardRunNeedsConfirm(realAppId)).isFalse() + assertThat(check(installed = false, factory = null).standardRunNeedsConfirm(realAppId)).isFalse() + } } From 11d884681fbbb4afc8b78dfe6db1c5132c0f44aa Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 2 Sep 2026 18:37:03 -0700 Subject: [PATCH 06/26] style: spotless reformat of DaemonProcessClient, no functional change ktlint joins start()'s single-expression body onto one line now that it delegates to startLocked. Standalone so it does not read as a behavioural change. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt index c53ba069ab..0235328dde 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt @@ -124,8 +124,7 @@ class DaemonProcessClient( * [DaemonReply.Failed] (spawn failure, protocol mismatch, or a rejected configuration) with * the child shut down first, so a failed start never leaves a daemon behind. */ - override suspend fun start(config: DaemonConfig): DaemonReply = - startMutex.withLock { startLocked(config) } + override suspend fun start(config: DaemonConfig): DaemonReply = startMutex.withLock { startLocked(config) } /** * The [start] body, run under [startMutex]. From b7cabc0e1f88e4bab57968d6cab7fc9e674a496e Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 12:49:10 -0700 Subject: [PATCH 07/26] ADFA-4128: shutdown takes the start mutex, and the polite stop runs uncancellable Answers review threads 3926544377 and 3926550446 on PR #1719. shutdown() now takes startMutex and delegates to an unlocked shutdownLocked(), which start's own pre-spawn stop and its failure tail call directly - the mutex is not reentrant. A teardown landing mid-spawn now waits for the child to be installed instead of reading a null handle and leaving it with no death watcher and nothing to stop it. The polite SHUTDOWN request moves inside the NonCancellable block. request() rethrows CancellationException, so a cancellation one line above it skipped the kill, the pipe close and the handle clear - the leak the block exists to prevent. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../quickbuild/data/DaemonProcessClient.kt | 26 +++-- .../data/DaemonProcessClientEdgeTest.kt | 99 ++++++++++++++++++- 2 files changed, 115 insertions(+), 10 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt index 0235328dde..51cbd2adf1 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt @@ -134,8 +134,9 @@ class DaemonProcessClient( */ private suspend fun startLocked(config: DaemonConfig): DaemonReply { // Also clears scratchFsType and marks the old child's stop on its own Spawn - the - // fresh Spawn below starts with a clean marker of its own. - shutdown() + // fresh Spawn below starts with a clean marker of its own. The unlocked body, because + // [startMutex] is already held here and is not reentrant. + shutdownLocked() val proc = try { @@ -227,7 +228,8 @@ class DaemonProcessClient( // else shuts it down, so it would hold its heap for the rest of the app's life and fire // deathListener for a session that never had a daemon. if (outcome !is DaemonReply.Ok) { - shutdown() + // The unlocked body: [startMutex] is held for the whole of this method. + shutdownLocked() } return outcome } @@ -331,8 +333,16 @@ class DaemonProcessClient( /** * Stops the child politely, then forcibly, and clears the process handles. A no-op when * nothing is running; the exit it causes is marked deliberate so no death listener fires. + * + * Takes [startMutex] so a teardown landing mid-spawn waits for the child to be installed + * instead of reading a null handle and orphaning it for the life of the process. + */ + override suspend fun shutdown() = startMutex.withLock { shutdownLocked() } + + /** + * The [shutdown] body, run under [startMutex]. */ - override suspend fun shutdown() { + private suspend fun shutdownLocked() { val spawn = this.spawn ?: return // Marked before anything can kill it, so every exit from here on is deliberate to the // watcher no matter how late it observes it. @@ -341,13 +351,15 @@ class DaemonProcessClient( // Belongs to the child being stopped: left in place it would stamp the previous // daemon's filesystem on the next session's timings. scratchFsType = null - // Best effort polite stop; the protocol also treats stdin EOF as shutdown. - withTimeoutOrNull(SHUTDOWN_TIMEOUT_MILLIS) { request(DaemonOps.SHUTDOWN) {} } val proc = spawn.process val out = spawn.writer // NonCancellable because this is the only thing that kills the child: a teardown - // cancelled at the waitFor would leave a live JVM nothing else ever stops. + // cancelled anywhere from the polite request to the waitFor would leave a live JVM + // nothing else ever stops. request() rethrows CancellationException, so the polite + // stop has to be inside the block rather than one line above it. withContext(Dispatchers.IO + NonCancellable) { + // Best effort polite stop; the protocol also treats stdin EOF as shutdown. + withTimeoutOrNull(SHUTDOWN_TIMEOUT_MILLIS) { request(DaemonOps.SHUTDOWN) {} } // EOF is the second polite signal, but close() blocks on the BufferedWriter // monitor while a wedged write holds it - so it runs on [scope] rather than // inline, and the kill path below (which closes the pipe and thereby frees any diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt index ec2195efdc..e6f30d799f 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt @@ -34,6 +34,8 @@ class DaemonProcessClientEdgeTest { private class ScriptedPaths( base: File, override val javaBinary: File, + /** Runs inside the spawn, so a test can hold [DaemonProcessClient.start] mid-spawn. */ + private val onEnvironment: () -> Unit = {}, ) : QuickBuildPaths { override val daemonJar = File(base, "daemon/quickbuild-daemon.jar") override val runtimeAar = File(base, "quickbuild-runtime.aar") @@ -43,16 +45,22 @@ class DaemonProcessClientEdgeTest { override val androidJar = File(base, "android.jar") override val projectScratchRoot = File(base, "app-private/quickbuild-scratch") - override fun daemonEnvironment(): Map = mapOf("PATH" to "/usr/bin:/bin") + override fun daemonEnvironment(): Map { + onEnvironment() + return mapOf("PATH" to "/usr/bin:/bin") + } } /** Writes a fake-java script with [body] as its full shell text and returns paths using it. */ - private fun scriptedPaths(body: String): ScriptedPaths { + private fun scriptedPaths( + body: String, + onEnvironment: () -> Unit = {}, + ): ScriptedPaths { val script = File(tmp, "fake-java.sh") script.writeText("#!/bin/sh\n$body\n") script.setExecutable(true) File(tmp, "daemon").mkdirs() - return ScriptedPaths(tmp, script) + return ScriptedPaths(tmp, script, onEnvironment) } /** @@ -1414,4 +1422,89 @@ class DaemonProcessClientEdgeTest { assertThat(File(tmp, "configure-request.txt").readText()) .contains("\"minApi\":${ConfigureRequest.DEFAULT_MIN_API}") } + + /** + * The orphan window: startLocked's own shutdown nulls the handle, and nothing re-installs + * it until the child is spawned. An unlocked shutdown landing in there reads null, returns + * a no-op, and the child start then installs has no death watcher and nothing holding it. + */ + @Test + fun `a shutdown landing mid-spawn still stops the child that spawn installs`() { + val inSpawn = CountDownLatch(1) + val releaseSpawn = CountDownLatch(1) + val paths = + scriptedPaths( + replyOk + + "\n" + + """ + printf '%s' "${'$'}${'$'}" > '$tmp/daemon.pid' + while read line; do reply "${'$'}line"; done + """.trimIndent(), + onEnvironment = { + inSpawn.countDown() + check(releaseSpawn.await(30, TimeUnit.SECONDS)) + }, + ) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val client = DaemonProcessClient(paths, scope) + + try { + runBlocking { + val startJob = scope.async { client.start(config()) } + check(inSpawn.await(30, TimeUnit.SECONDS)) + val shutdownJob = scope.async { client.shutdown() } + // Unfixed, shutdown finishes here on a null handle, before the child exists; + // fixed, it is parked on the start mutex for the whole spawn. + delay(500) + releaseSpawn.countDown() + assertThat(startJob.await()).isInstanceOf(DaemonReply.Ok::class.java) + withTimeout(30_000) { shutdownJob.await() } + } + + val pid = File(tmp, "daemon.pid").readText().trim() + assertThat(isProcessAlive(pid)).isFalse() + } finally { + releaseSpawn.countDown() + runBlocking { client.shutdown() } + scope.cancel() + } + } + + /** + * The polite request runs inside the uncancellable block, so a teardown cancelled while it + * is in flight still reaches the kill. Outside it, request() rethrows the cancellation and + * the kill, the pipe close and the handle clear are all skipped. + */ + @Test + fun `a shutdown cancelled during the polite request still kills the child`() { + val paths = + scriptedPaths( + """ + printf '%s' "${'$'}${'$'}" > '$tmp/daemon.pid' + read line + printf '%s\n' '${okConfigure()}' + exec sleep 120 + """.trimIndent(), + ) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val client = DaemonProcessClient(paths, scope) + + try { + runBlocking { + check(client.start(config()) is DaemonReply.Ok) + val shutdownJob = scope.async { client.shutdown() } + // Parked in the polite request, which this daemon never answers. + delay(500) + shutdownJob.cancel() + // join, not await: the body still has its own shutdown timeout to spend. + withTimeout(30_000) { shutdownJob.join() } + } + + val pid = File(tmp, "daemon.pid").readText().trim() + assertThat(isProcessAlive(pid)).isFalse() + } finally { + runBlocking { client.shutdown() } + scope.cancel() + } + } } From 674a2273359896e1fbc629cd077203a75f09a257 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 12:49:21 -0700 Subject: [PATCH 08/26] ADFA-4128: guard every installed-package read, and state why OTHER is not terminal Answers review threads 3926544390 and 3926544402 on PR #1719. ensureInstalled promises never to throw, but its InstalledPackages reads run in a plain coroutineScope, so any of them throwing cancels the scope and raises out of it. All five reads now go through one readPackages helper that maps a throw to null: the two Akash named (awaitStampChange, resolveUid) plus three he did not - the initial stamp, the existing uid, and the installed-APK lookup behind isSameContent. isTerminal's omission of Status.OTHER stays. OTHER is the mapper's catch-all for an unrecognized status and the receiver's action is exported, so making it terminal would let a stray external intent abort a legitimate install. The KDoc now says that and names the test that pins it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../service/provision/ProxyAppInstaller.kt | 49 ++++++++++++--- .../provision/ProxyAppInstallerTest.kt | 62 ++++++++++++++++++- 2 files changed, 100 insertions(+), 11 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt index 954d6509e5..224a74ad2b 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt @@ -87,7 +87,17 @@ data class InstallBroadcast( /** ABORTED is STATUS_FAILURE_ABORTED: the user cancelled the confirm dialog. */ enum class Status { SUCCESS, FAILURE, ABORTED, PENDING_USER_ACTION, OTHER } - /** True when no further broadcast will follow for this install. */ + /** + * True when no further broadcast will follow for this install. + * + * [Status.OTHER] is deliberately not terminal. It is the mapper's catch-all for a + * broadcast whose status extra it does not recognize, and the receiver's action is + * exported, so treating it as terminal would let a stray external intent abort a + * legitimate install. Every documented PackageInstaller failure code maps to + * [Status.FAILURE] instead. ProxyAppInstallerTest's "changed bytes reinstall and resolve + * via the success broadcast" pins the omission by emitting OTHER mid-wait and requiring + * the later SUCCESS to be what settles the install. + */ val isTerminal: Boolean get() = status == Status.SUCCESS || status == Status.FAILURE || status == Status.ABORTED } @@ -190,8 +200,8 @@ class ProxyAppInstaller( apk: File, packageName: String, ): InstallOutcome { - val initialStamp = withContext(ioDispatcher) { packages.lastUpdateTime(packageName) } - val existingUid = withContext(ioDispatcher) { packages.uid(packageName) } + val initialStamp = readPackages { packages.lastUpdateTime(packageName) } + val existingUid = readPackages { packages.uid(packageName) } if (existingUid != null && isSameContent(apk, packageName)) { log.info("{} already runs these bytes; skipping reinstall", packageName) return InstallOutcome.Installed(existingUid) @@ -404,7 +414,7 @@ class ProxyAppInstaller( initialStamp: Long?, ) { while (true) { - val stamp = withContext(ioDispatcher) { packages.lastUpdateTime(packageName) } + val stamp = readPackages { packages.lastUpdateTime(packageName) } if (stamp != null && stamp != initialStamp) return delay(DEFAULT_POLL_MILLIS) } @@ -420,7 +430,7 @@ class ProxyAppInstaller( // The uid should exist the moment the install lands; retry briefly for the // window between the success broadcast and PackageManager visibility. repeat(UID_RETRIES) { - withContext(ioDispatcher) { packages.uid(packageName) } + readPackages { packages.uid(packageName) } ?.let { return InstallOutcome.Installed(it) } delay(DEFAULT_POLL_MILLIS) } @@ -438,14 +448,37 @@ class ProxyAppInstaller( private suspend fun isSameContent( apk: File, packageName: String, - ): Boolean = - withContext(ioDispatcher) { + ): Boolean { + val installed = readPackages { packages.apkFile(packageName) } ?: return false + return withContext(ioDispatcher) { // Two full-APK hashes; off the caller's dispatcher (concurrency.md forbids // blocking the session thread). - val installed = packages.apkFile(packageName) ?: return@withContext false val candidate = sha256OrNull(apk) ?: return@withContext false candidate == sha256OrNull(installed) } + } + + /** + * Runs one [InstalledPackages] read on [ioDispatcher], mapping a throw to null. + * + * The interface returns null for "not installed" but does not forbid an implementation + * throwing, and [ensureInstalled] runs its reads inside a plain `coroutineScope`, so an + * unguarded throw in any of them cancels the scope and breaks the never-throws contract. + * + * @param read the lookup to run + * @return what the lookup returned, or null when it was absent or the read failed + */ + private suspend fun readPackages(read: () -> T?): T? = + withContext(ioDispatcher) { + try { + read() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.warn("installed-package lookup failed", e) + null + } + } companion object { private val log = LoggerFactory.getLogger("QB-ProxyInstaller") diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt index 817a59a5af..1e900d21d7 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt @@ -35,11 +35,26 @@ class ProxyAppInstallerTest { var stamp: Long? = null var installedApk: File? = null - override fun uid(packageName: String): Int? = uid + /** Makes the uid and stamp lookups throw, standing in for a PackageManager fault. */ + var lookupsThrow = false - override fun lastUpdateTime(packageName: String): Long? = stamp + /** Makes the installed-APK lookup throw. */ + var apkFileThrows = false - override fun apkFile(packageName: String): File? = installedApk + override fun uid(packageName: String): Int? { + check(!lookupsThrow) { "uid lookup failed" } + return uid + } + + override fun lastUpdateTime(packageName: String): Long? { + check(!lookupsThrow) { "lastUpdateTime lookup failed" } + return stamp + } + + override fun apkFile(packageName: String): File? { + check(!apkFileThrows) { "apkFile lookup failed" } + return installedApk + } override fun signingCertSha256(packageName: String): String? = null @@ -595,6 +610,47 @@ class ProxyAppInstallerTest { assertThat(thrown).isInstanceOf(CancellationException::class.java) } + /** + * The never-throws contract covers every InstalledPackages read, not just the one inside + * the verdict async: the reads run in a plain coroutineScope, so an unguarded throw in any + * of them cancels the scope and raises out of ensureInstalled instead of returning. + */ + @Test + fun `uid and stamp lookups that throw still come back as an outcome`() = + runTest { + packages.lookupsThrow = true + + val result = async { installer().ensureInstalled(apk, PKG) } + runCurrent() + assertThat(installLaunches).containsExactly(apk) + + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.SUCCESS)) + advanceUntilIdle() + + // The uid never resolves, so the install is reported unresolvable - the point is + // that a value comes back at all. + assertThat(result.await()) + .isEqualTo(InstallOutcome.Failed(QuickBuildMessage.InstalledButUnresolvable(PKG))) + } + + @Test + fun `an installed-APK lookup that throws reinstalls instead of raising`() = + runTest { + packages.uid = 10123 + packages.stamp = 111L + packages.apkFileThrows = true + + val result = async { installer().ensureInstalled(apk, PKG) } + runCurrent() + // Unreadable reads as "not the same bytes", which is the safe direction. + assertThat(installLaunches).containsExactly(apk) + + broadcasts.emit(InstallBroadcast(PKG, InstallBroadcast.Status.SUCCESS)) + advanceUntilIdle() + + assertThat(result.await()).isEqualTo(InstallOutcome.Installed(10123)) + } + @Test fun `sha256 digests real content and returns null for a missing file`() { assertThat(ProxyAppInstaller.sha256OrNull(apk)) From 24160642bd82903256a13a565dbf50556420198b Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 12:49:34 -0700 Subject: [PATCH 09/26] ADFA-4128: drop blank classpath entries; deadline before the isRunning branch Answers review threads 3926544396, 3926551516, 3926550898 and 3926552396 on PR #1719. classpath and payloadJars now go through the same stringArray helper their siblings use, which drops blanks. A blank resolved to the project root, putting the whole tree on the daemon's compile classpath. shrinkIfPending checks the pending-teardown deadline before the isRunning branch. The common shape is a trim raised by a Gradle build: it defers, and the retry lands minutes later with the daemon healthy, where the deadline never ran - so it tore down a daemon the user is using over memory pressure long gone. Adds the file-at-target generation-store test that was promised but absent, so the arm that preserves the counter is executed, and drops the last fully-qualified withContext call site in the test fakes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../cotg/quickbuild/data/ProxyAppInfo.kt | 16 +++------- .../session/QuickBuildDaemonController.kt | 24 +++++++------- .../data/FileGenerationStoreEdgeTest.kt | 27 ++++++++++++++++ .../quickbuild/data/ProxyAppInfoEdgeTest.kt | 18 +++++++++++ .../cotg/quickbuild/service/Fakes.kt | 4 ++- .../session/QuickBuildDaemonControllerTest.kt | 31 ++++++++++++++++++- 6 files changed, 95 insertions(+), 25 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt index 5d4c698751..8510917021 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt @@ -139,20 +139,12 @@ data class ProxyAppInfo( val entry = obj.firstString("entryActivity", "mainActivity") val apkPath = obj.firstString("apk", "apkPath", "apkFile") ?: return missing("apk") - val classpath = - obj - .jsonArray("classpath") - ?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString } - ?.map { resolve(it, baseDir) } - ?: emptyList() + // Through stringArray, which drops blanks: resolve turns an empty entry into + // baseDir, putting the whole project tree on the daemon's compile classpath. + val classpath = obj.stringArray("classpath").map { resolve(it, baseDir) } // Generated project-scope jars (R.jar and kin) ride the compile classpath: // hot compiles reference R, which the variant compile classpath lacks. - val payloadJars = - obj - .jsonArray("payloadJars") - ?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString } - ?.map { resolve(it, baseDir) } - ?: emptyList() + val payloadJars = obj.stringArray("payloadJars").map { resolve(it, baseDir) } return ProxyAppInfo( proxyAppPackage = pkg, diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt index c382dec564..5449a4bcca 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt @@ -195,25 +195,27 @@ internal class QuickBuildDaemonController( * Carries out a deferred low-memory teardown once no build is in flight. * * A build in flight leaves the pending flag set for the manager's state collector to - * retry. Idempotent: with no pending request this is a silent no-op, and a daemon that is - * down keeps the request only until [PENDING_TEARDOWN_DEADLINE_MILLIS] has passed. + * retry. Idempotent: with no pending request this is a silent no-op, and any request is + * dropped once [PENDING_TEARDOWN_DEADLINE_MILLIS] has passed, whether or not the daemon + * came back in the meantime. * * @param buildInFlight true to leave the request pending for a later call */ suspend fun shrinkIfPending(buildInFlight: Boolean) { if (buildInFlight) return if (!pendingLowMemoryTeardown) return - // Consumed only past the isRunning guard: clearing first would discard the request - // while the daemon is briefly down, not the silent no-op the KDoc promises. Held - // requests do expire, though - past the deadline the memory pressure that asked for - // this is old news, and acting on it would tear down a daemon the user is using. - if (!daemon.isRunning) { - if (now() - pendingLowMemoryTeardownAt >= PENDING_TEARDOWN_DEADLINE_MILLIS) { - log.debug("Quick Build: dropping a low-memory teardown the daemon never came back for") - pendingLowMemoryTeardown = false - } + // Ahead of the isRunning branch, because the common shape is a trim raised BY a + // build: it defers, then lands here minutes later with the daemon healthy. Checked + // inside that branch the deadline never runs for it, and the request tears down a + // daemon the user is using over memory pressure that is long gone. + if (now() - pendingLowMemoryTeardownAt >= PENDING_TEARDOWN_DEADLINE_MILLIS) { + log.debug("Quick Build: dropping a low-memory teardown that outlived its deadline") + pendingLowMemoryTeardown = false return } + // Consumed only past the isRunning guard: clearing first would discard the request + // while the daemon is briefly down, not the silent no-op the KDoc promises. + if (!daemon.isRunning) return pendingLowMemoryTeardown = false log.info("Quick Build: tearing down the compile daemon for low memory; the next build re-warms it") markIntentionalTransition() diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreEdgeTest.kt index 854f1fa4f7..4ea052229b 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreEdgeTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreEdgeTest.kt @@ -53,6 +53,33 @@ class FileGenerationStoreEdgeTest { assertThat(store.load()).isEqualTo(42) } + /** + * The arm that keeps the counter when neither rename can land: the old value is already + * deleted by then, so a throw would restart the sequence and let a later session reuse a + * generation the installed proxy app has already seen. + * + * The fixture defeats the retry rather than the write. An empty directory at the target + * refuses the direct rename; the override then removes the staged temp along with that + * directory, so the retry has nothing left to move and the direct write is the only way + * the value can survive. + */ + @Test + fun `save writes the counter directly when the retry rename cannot run`() { + val path = File(tmp, "generation").apply { mkdirs() } + val target = + object : File(path.absolutePath) { + override fun delete(): Boolean { + File(parentFile, "$name.tmp").delete() + return super.delete() + } + } + val store = FileGenerationStore(target) + + store.save(42) + + assertThat(FileGenerationStore(path).load()).isEqualTo(42) + } + @Test fun `save throws when the target cannot be replaced at all`() { // A NON-empty directory defeats both the rename and the delete; the store must diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfoEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfoEdgeTest.kt index 7e5316d9e9..9d6c510fc0 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfoEdgeTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfoEdgeTest.kt @@ -149,6 +149,24 @@ class ProxyAppInfoEdgeTest { assertThat(info!!.classpath).containsExactly(File("/project/libs/a.jar")) } + /** + * A blank entry resolves to the base directory, which would put the whole project tree on + * the daemon's compile classpath - so both lists drop blanks the way the shared helper + * their siblings go through does. + */ + @Test + fun `blank classpath and payloadJars entries are dropped`() { + val info = + ProxyAppInfo.parse( + json(""","classpath": ["", "libs/a.jar"], "payloadJars": [" ", "build/R.jar"]"""), + baseDir, + ) + + assertThat(info!!.classpath) + .containsExactly(File("/project/libs/a.jar"), File("/project/build/R.jar")) + .inOrder() + } + @Test fun `optional file fields default to null when absent`() { val info = ProxyAppInfo.parse(json(), baseDir) diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt index 07508f0272..f737b85a32 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt @@ -1,6 +1,8 @@ package org.appdevforall.cotg.quickbuild.service import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import org.appdevforall.cotg.quickbuild.data.CompileOutput import org.appdevforall.cotg.quickbuild.data.DaemonConfig import org.appdevforall.cotg.quickbuild.data.DaemonReply @@ -72,7 +74,7 @@ class FakeDaemon : QuickBuildDaemon { startGate?.let { gate -> startGate = null if (startSurvivesCancel) { - kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { gate.await() } + withContext(NonCancellable) { gate.await() } } else { gate.await() } diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonControllerTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonControllerTest.kt index c579e4d568..c5653bff42 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonControllerTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonControllerTest.kt @@ -28,11 +28,12 @@ class QuickBuildDaemonControllerTest { private val daemon = FakeDaemon() - private fun controller() = + private fun controller(now: () -> Long = System::currentTimeMillis) = QuickBuildDaemonController( daemon = daemon, scratch = QuickBuildScratch(FakePaths(projectRoot).projectScratchRoot), paths = FakePaths(projectRoot), + now = now, ) private fun proxyApp(minApi: Int = ConfigureRequest.DEFAULT_MIN_API) = @@ -210,6 +211,34 @@ class QuickBuildDaemonControllerTest { assertThat(controller.epochSnapshot()).isEqualTo(1L) } + /** + * The common shape: a Gradle build peaks memory, so the trim it raises defers, and the + * retry only lands when that build finishes minutes later - with the daemon healthy, which + * is the branch the deadline used not to be consulted in. + */ + @Test + fun `a shrink deferred behind a build is dropped once the deadline has passed`() = + runTest { + var clock = 0L + val controller = controller(now = { clock }) + daemon.isRunning = true + controller.onTrimMemory( + ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL, + buildInFlight = true, + ) + + // Ten minutes: a multi-minute Gradle build is what the trim was raised by, and + // the deadline is private to the controller. + clock = 600_000L + controller.shrinkIfPending(buildInFlight = false) + assertThat(daemon.shutdownCount).isEqualTo(0) + assertThat(controller.epochSnapshot()).isEqualTo(0L) + + // Dropped, not merely skipped: a later retry must not resurrect it either. + controller.shrinkIfPending(buildInFlight = false) + assertThat(daemon.shutdownCount).isEqualTo(0) + } + @Test fun `a shrink retried while the daemon is briefly down keeps the request pending`() = runTest { From a7531a95b61ef41be1685f3ff465b43fbedfe543 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 16:25:39 -0700 Subject: [PATCH 10/26] ADFA-4128: pin the pump drain with a daemon that replies and exits in one breath The watcher's drain join (wait for stdout EOF before failing the pending requests) had no test: the existing mid-request death script exits without writing a reply, so it lands on the same Failed result with or without the join. This script writes the reply and exits at once, behind a burst of id-less lines that keeps the pump busy past waitFor, and asserts the Ok arrives. Verified against the join removed: fails 3 of 3 runs for the reason it is named for; with the join, passes 3 of 3. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../data/DaemonProcessClientEdgeTest.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt index e6f30d799f..f8a2cea98b 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt @@ -326,6 +326,37 @@ class DaemonProcessClientEdgeTest { assertThat(failed.daemonDied).isTrue() } + @Test + fun `a reply written in the same breath as the exit is delivered, not discarded`() { + // The child answers and exits at once, so the reply bytes are still in the pipe + // when waitFor returns. The watcher must let the pump drain before it fails the + // pending request; without that join the written reply lands as a response for an + // unknown id and the caller sees a dead daemon instead of its Ok. The burst of + // id-less lines ahead of the reply is what makes the race lose deterministically: + // the child exits with the pipe buffer full, so the pump is still parsing when + // waitFor returns and the reply is the last thing it reads. + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + i=0 + while [ ${'$'}i -lt 4000 ]; do printf '%s\n' '{"noise":"the pump must parse this line before the reply"}'; i=${'$'}((i+1)); done + printf '%s\n' '{"id":2,"ok":true,"classesDir":"/out/classes"}' + exit 0 + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.compile(emptyList(), emptyList()) + } + + assertThat(reply).isInstanceOf(DaemonReply.Ok::class.java) + } + @Test fun `a replaced child's watcher frees its own in-flight request instead of orphaning it`() { // A request holds requestMutex for its whole round trip. When the child dies while From 6c1c38a3aee08541ba77b45ffb3f1063aa588e78 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:50:44 -0700 Subject: [PATCH 11/26] ADFA-4128: let the start, not the watcher's timing, decide whether an exit is a death 7cc417596 marked a child deliberate until its configure succeeded, which closed the "dies during configure" case but opened the opposite one: a child that writes its configure reply and exits in the same breath can be observed by the death watcher before the start has read that reply and cleared the marker, and its death is swallowed. The existing test for an unexpected exit after configure caught it on the third run. Spawn.owned is a CompletableDeferred the start settles - true once the reply passed the version check, false on every other exit of startLocked including cancellation - and the watcher waits on it after failing this child's pending requests (which is what lets a start still inside its configure round trip finish). The marker goes back to starting false and means only what shutdown says it means. Review thread: https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3934047629 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../quickbuild/data/DaemonProcessClient.kt | 39 +++++++++++++++++-- .../data/DaemonProcessClientEdgeTest.kt | 38 ++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt index 51cbd2adf1..393c95896b 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt @@ -85,6 +85,17 @@ class DaemonProcessClient( */ val deliberateStop = AtomicBoolean(false) + /** + * Whether the session ever owned this child: true once its `configure` reply passed + * [startLocked]'s checks, false when the start failed or was cancelled. The watcher + * waits on it before reporting an exit, because a child can write its reply and exit in + * the same breath and the watcher can wake before the start has read that reply - a + * marker read at that moment calls a death a failed start, or the reverse. A child that + * dies during its own configure is a failed [start], which the caller already sees as a + * [DaemonReply.Failed]; only an owned child's exit is a death. + */ + val owned = CompletableDeferred() + /** * This child's stdout pump, so the death watcher can drain it before failing pending * requests. Assigned by [startReaders] before the watcher can observe an exit. @@ -167,7 +178,22 @@ class DaemonProcessClient( val spawn = Spawn(proc, proc.outputStream.bufferedWriter()) this.spawn = spawn startReaders(spawn) + try { + return configureLocked(spawn, config) + } finally { + // No-op after a configured start; every other way out, cancellation included, tells + // the watcher this child was never the session's. + spawn.owned.complete(false) + } + } + /** + * The `configure` round trip of [startLocked], on a child already installed as [spawn]. + */ + private suspend fun configureLocked( + spawn: Spawn, + config: DaemonConfig, + ): DaemonReply { val configureReply = request(DaemonOps.CONFIGURE) { addProperty(RequestKeys.PROJECT_ROOT, config.projectRoot.absolutePath) @@ -206,6 +232,8 @@ class DaemonProcessClient( ?.takeIf { it.isJsonPrimitive } ?.asString configured = true + // Only now is an exit a death: from here the session owns this child. + spawn.owned.complete(true) DaemonReply.Ok(Unit) } } @@ -225,8 +253,9 @@ class DaemonProcessClient( } } // A start that never reached a configured daemon must not leave the child behind: nothing - // else shuts it down, so it would hold its heap for the rest of the app's life and fire - // deathListener for a session that never had a daemon. + // else shuts it down, so a child that hangs would hold its heap for the rest of the + // app's life. A child that DIES here is already covered - [Spawn.owned] settles false, + // so the watcher reports no death for a session that never had a daemon. if (outcome !is DaemonReply.Ok) { // The unlocked body: [startMutex] is held for the whole of this method. shutdownLocked() @@ -536,8 +565,12 @@ class DaemonProcessClient( return@launch } configured = false + // Settled by the start, not by which coroutine woke first - see [Spawn.owned]. Safe + // to wait on: this child's pending requests were failed above, which is what lets + // a start still inside its configure round trip finish and settle it. + val wasOwned = spawn.owned.await() // This child's own marker, not a shared flag - see [Spawn.deliberateStop]. - if (!spawn.deliberateStop.get()) { + if (wasOwned && !spawn.deliberateStop.get()) { log.error("Quick-build daemon died with exit code {}", exitCode) deathListener?.invoke(exitCode) } diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt index f8a2cea98b..ecfcaf4cd1 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt @@ -1538,4 +1538,42 @@ class DaemonProcessClientEdgeTest { scope.cancel() } } + + @Test + fun `a child that dies during configure fails the start without firing the death listener`() { + // The child exits before answering configure - a corrupt staged jar, an OEM image that + // aborts the JVM. start() reports the failure itself, so a death report on top would + // name a session that never had a daemon. Without the marker set for the duration of + // start() this was a coin flip between the watcher's read and start()'s own cleanup. + val paths = + scriptedPaths( + """ + read line + exit 5 + """.trimIndent(), + ) + val deaths = CopyOnWriteArrayList() + val supervisor = SupervisorJob() + val scope = CoroutineScope(supervisor + Dispatchers.IO) + val client = DaemonProcessClient(paths, scope, requestTimeoutMillis = 10_000) + + val reply = + try { + runBlocking { + client.setDeathListener { deaths.add(it) } + val reply = client.start(config()) + // The watcher is the only thing that could call the listener; once it has + // completed, a late call is impossible rather than merely unobserved. + withTimeout(30_000) { supervisor.children.toList().forEach { it.join() } } + reply + } + } finally { + runBlocking { client.shutdown() } + scope.cancel() + } + + assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + assertThat((reply as DaemonReply.Failed).daemonDied).isTrue() + assertThat(deaths).isEmpty() + } } From 5cc9df5e78a778424acb3896585ebf4a18633954 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:50:44 -0700 Subject: [PATCH 12/26] ADFA-4128: carry a successful compile's warnings on CompileOutput The daemon sends diagnostics on an ok compile reply too, and the client read them only off failures, so no warning from a quick build could reach the user. CompileOutput now carries them; the executor's success arm on the orchestration branch and the output lines on the app branch surface them at the restack. Dex and relink are unchanged: the daemon builds those replies through DaemonResponse.ok, which carries none. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3934045924 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../quickbuild/data/DaemonProcessClient.kt | 7 +++-- .../cotg/quickbuild/data/QuickBuildDaemon.kt | 4 +++ .../data/DaemonProcessClientEdgeTest.kt | 30 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt index 393c95896b..e90db35a93 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt @@ -300,6 +300,9 @@ class DaemonProcessClient( kotlinMillis = response.longOrNull(ResponseKeys.KOTLIN_MILLIS), javaMillis = response.longOrNull(ResponseKeys.JAVA_MILLIS), stats = CompileStats.fromValues { key -> response.longOrNull(key) }, + // A build can succeed with warnings, and the daemon sends them on the Ok reply + // too; dropped here they would reach no one. + diagnostics = response?.let(::parseDiagnostics).orEmpty(), ) } } @@ -578,9 +581,9 @@ class DaemonProcessClient( } /** - * Reads the `diagnostics` array off a failed response. + * Reads the `diagnostics` array off a response - failed, or a success carrying warnings. * - * @param response the `ok=false` response object. + * @param response the response object. * @return one [BuildDiagnostic] per well-formed entry, empty when the key is absent or not an * array; anything but an explicit `WARNING` reads as an error, a missing or non-primitive * message becomes "unknown error", and a non-numeric line or column reads as absent, so a diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildDaemon.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildDaemon.kt index 44e2663467..a7e2723075 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildDaemon.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildDaemon.kt @@ -110,6 +110,9 @@ interface QuickBuildDaemon { * @property javaMillis wall time of the daemon's javac pass. * @property stats the phases [kotlinMillis]/[javaMillis] do not cover (output-tree * snapshots, the Java-ABI re-parse) plus this build's counts. + * @property diagnostics the warnings a successful build still produced, in the daemon's order; + * empty when it reported none. A success never carries an ERROR - that is a + * [DaemonReply.BuildFailed]. */ data class CompileOutput( val classesDir: File, @@ -117,6 +120,7 @@ data class CompileOutput( val kotlinMillis: Long? = null, val javaMillis: Long? = null, val stats: CompileStats? = null, + val diagnostics: List = emptyList(), ) /** diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt index ecfcaf4cd1..9f9123706e 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt @@ -1576,4 +1576,34 @@ class DaemonProcessClientEdgeTest { assertThat((reply as DaemonReply.Failed).daemonDied).isTrue() assertThat(deaths).isEmpty() } + + @Test + fun `a successful compile carries the daemon's warnings`() { + // The daemon sends diagnostics on an ok reply too (a build can succeed with warnings); + // a client that reads them only off failures shows the user a clean build for an edit + // Gradle would have warned about. + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":true,"classesDir":"/out/classes","diagnostics":[{"severity":"WARNING","message":"deprecated","file":"/src/A.kt","line":3,"column":9}]}' + read line + printf '%s\n' '{"id":3,"ok":true}' + """.trimIndent(), + ) + + val reply = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + client.compile(emptyList(), emptyList()) + } + + val output = (reply as DaemonReply.Ok).value + assertThat(output.diagnostics) + .containsExactly( + BuildDiagnostic(BuildDiagnostic.Severity.WARNING, "deprecated", "/src/A.kt", 3, 9), + ) + } } From 83a5156c012450db1a4b6d01b34d8e67cfd2f99f Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:50:44 -0700 Subject: [PATCH 13/26] ADFA-4128: read the daemon's own op duration into daemonMillis Every op reports durationMillis and the client read none of them, so the daemon's in-process cost could not be set against the client's round trip. Each output now carries it beside its phase timings. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3934045960 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../quickbuild/data/DaemonProcessClient.kt | 3 ++ .../cotg/quickbuild/data/QuickBuildDaemon.kt | 7 ++++ .../data/DaemonProcessClientEdgeTest.kt | 34 +++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt index e90db35a93..69d996cafc 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt @@ -299,6 +299,7 @@ class DaemonProcessClient( changed, kotlinMillis = response.longOrNull(ResponseKeys.KOTLIN_MILLIS), javaMillis = response.longOrNull(ResponseKeys.JAVA_MILLIS), + daemonMillis = response.longOrNull(ResponseKeys.DURATION_MILLIS), stats = CompileStats.fromValues { key -> response.longOrNull(key) }, // A build can succeed with warnings, and the daemon sends them on the Ok reply // too; dropped here they would reach no one. @@ -326,6 +327,7 @@ class DaemonProcessClient( it, stripMillis = response.longOrNull(ResponseKeys.STRIP_MILLIS), d8Millis = response.longOrNull(ResponseKeys.D8_MILLIS), + daemonMillis = response.longOrNull(ResponseKeys.DURATION_MILLIS), stats = DexStats.fromValues { key -> response.longOrNull(key) }, ) } @@ -355,6 +357,7 @@ class DaemonProcessClient( it, aapt2CompileMillis = response.longOrNull(ResponseKeys.AAPT2_COMPILE_MILLIS), aapt2LinkMillis = response.longOrNull(ResponseKeys.AAPT2_LINK_MILLIS), + daemonMillis = response.longOrNull(ResponseKeys.DURATION_MILLIS), ) } } diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildDaemon.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildDaemon.kt index a7e2723075..f24c356b08 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildDaemon.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildDaemon.kt @@ -108,6 +108,8 @@ interface QuickBuildDaemon { * @property kotlinMillis wall time of the daemon's Kotlin pass; null when unreported, as for * every step-timing field below. * @property javaMillis wall time of the daemon's javac pass. + * @property daemonMillis the whole op as the daemon timed it, request read to reply written; + * against the client's own round trip it isolates the pipe and parse cost. * @property stats the phases [kotlinMillis]/[javaMillis] do not cover (output-tree * snapshots, the Java-ABI re-parse) plus this build's counts. * @property diagnostics the warnings a successful build still produced, in the daemon's order; @@ -119,6 +121,7 @@ data class CompileOutput( val changedClassFiles: List?, val kotlinMillis: Long? = null, val javaMillis: Long? = null, + val daemonMillis: Long? = null, val stats: CompileStats? = null, val diagnostics: List = emptyList(), ) @@ -130,12 +133,14 @@ data class CompileOutput( * @property dexFile the single `classes.dex` this op produced, ready to stage into a payload. * @property stripMillis wall time of the daemon's class-stripping pass; null when unreported. * @property d8Millis wall time of the d8 invocation itself; null when unreported. + * @property daemonMillis the whole op as the daemon timed it; see [CompileOutput.daemonMillis]. * @property stats how many classes / bytes the pass moved; null when unreported. */ data class DexOutput( val dexFile: File, val stripMillis: Long? = null, val d8Millis: Long? = null, + val daemonMillis: Long? = null, val stats: DexStats? = null, ) @@ -171,11 +176,13 @@ data class RelinkInputs( * not a bare table, since a bare table cannot back a file-typed resource. * @property aapt2CompileMillis wall time of the aapt2 compile pass; null when unreported. * @property aapt2LinkMillis wall time of the aapt2 link pass; null when unreported. + * @property daemonMillis the whole op as the daemon timed it; see [CompileOutput.daemonMillis]. */ data class RelinkOutput( val resourceApk: File, val aapt2CompileMillis: Long? = null, val aapt2LinkMillis: Long? = null, + val daemonMillis: Long? = null, ) /** diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt index 9f9123706e..901589a508 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt @@ -1606,4 +1606,38 @@ class DaemonProcessClientEdgeTest { BuildDiagnostic(BuildDiagnostic.Severity.WARNING, "deprecated", "/src/A.kt", 3, 9), ) } + + @Test + fun `every op reports the daemon's own duration beside its phase timings`() { + val paths = + scriptedPaths( + """ + read line + printf '%s\n' '${okConfigure()}' + read line + printf '%s\n' '{"id":2,"ok":true,"classesDir":"/out/classes","durationMillis":1200,"kotlinMillis":900}' + read line + printf '%s\n' '{"id":3,"ok":true,"dexFile":"/out/dex/classes.dex","durationMillis":300}' + read line + printf '%s\n' '{"id":4,"ok":true,"resourcesArsc":"/out/res/linked-res.apk","durationMillis":450}' + read line + printf '%s\n' '{"id":5,"ok":true}' + """.trimIndent(), + ) + + val (compile, dex, relink) = + withClient(paths) { client -> + check(client.start(config()) is DaemonReply.Ok) + Triple( + client.compile(emptyList(), emptyList()), + client.dex(emptyList()), + client.relink(RelinkInputs(resDirs = emptyList(), manifest = File(tmp, "AndroidManifest.xml"))), + ) + } + + assertThat((compile as DaemonReply.Ok).value.daemonMillis).isEqualTo(1200) + assertThat(compile.value.kotlinMillis).isEqualTo(900) + assertThat((dex as DaemonReply.Ok).value.daemonMillis).isEqualTo(300) + assertThat((relink as DaemonReply.Ok).value.daemonMillis).isEqualTo(450) + } } From cfd470a240aebd80a13e02d1d1ba9e3851570599 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:50:44 -0700 Subject: [PATCH 14/26] ADFA-4128: log the swallowed install-launch and stderr-drain exceptions InstallCouldNotStart reached the user with nothing in logcat behind it: both launchInstall catches dropped the throwable and the !started arm returned silently. The stderr drain's IOException catch was empty while the stdout pump logged the same close. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3934045939 https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3934045963 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt | 2 +- .../cotg/quickbuild/service/provision/ProxyAppInstaller.kt | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt index 69d996cafc..babde946e3 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt @@ -546,7 +546,7 @@ class DaemonProcessClient( log.warn("daemon(stderr): {}", line) } } catch (e: IOException) { - // stream closed with the process; nothing to do + log.debug("Daemon stderr closed: {}", e.message) } } scope.launch(Dispatchers.IO) { diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt index 224a74ad2b..b8aeed978a 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt @@ -251,9 +251,11 @@ class ProxyAppInstaller( } catch (e: CancellationException) { throw e } catch (e: Exception) { + log.error("could not start the install of {}", packageName, e) false } if (!started) { + log.warn("install of {} from {} did not start; reporting InstallCouldNotStart", packageName, apk) verdict.cancel() stampChanged.cancel() return@coroutineScope InstallOutcome.Failed(QuickBuildMessage.InstallCouldNotStart) @@ -296,6 +298,7 @@ class ProxyAppInstaller( throw e } catch (e: Exception) { // The first commit is still pending; keep waiting on it. + log.error("could not re-issue the install of {}", packageName, e) } } awaitVerdict() From c0486c2c3e26fbea623d0b9b83f443633a15c11e Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:50:44 -0700 Subject: [PATCH 15/26] ADFA-4128: keep a failed stamp read distinct from an absent package readPackages mapped a throw to null, and awaitStampChange read a null initial stamp as "absent, so any stamp counts" - one transient PackageManager throw over an installed package made the first poll match the old stamp and report an install that never ran. The pre-install read now says Unknown when it threw, and the poll establishes its baseline from the first successful read before waiting for a change. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3934045929 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../service/provision/ProxyAppInstaller.kt | 64 +++++++++++++++++-- .../provision/ProxyAppInstallerTest.kt | 37 +++++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt index b8aeed978a..538470bf46 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt @@ -200,7 +200,7 @@ class ProxyAppInstaller( apk: File, packageName: String, ): InstallOutcome { - val initialStamp = readPackages { packages.lastUpdateTime(packageName) } + val initialStamp = readStamp(packageName) val existingUid = readPackages { packages.uid(packageName) } if (existingUid != null && isSameContent(apk, packageName)) { log.info("{} already runs these bytes; skipping reinstall", packageName) @@ -406,19 +406,69 @@ class ProxyAppInstaller( } /** - * Polls until the package's lastUpdateTime moves off [initialStamp]. + * One read of a package's lastUpdateTime, keeping "absent" apart from "the read threw". + * + * The two must not collapse into one null: [awaitStampChange] reads an absent package as + * "any stamp at all is the install landing", and a transient PackageManager throw over an + * installed package would then match the old stamp and report an install that never ran. + */ + private sealed interface StampRead { + /** + * The read succeeded. + * + * @property stamp the package's lastUpdateTime, or null when it is not installed + */ + data class Known( + val stamp: Long?, + ) : StampRead + + /** The read threw, so nothing is known about the package either way. */ + data object Unknown : StampRead + } + + /** + * @param packageName the applicationId to look up + * @return the stamp, absence, or [StampRead.Unknown] when the lookup threw + */ + private suspend fun readStamp(packageName: String): StampRead = + withContext(ioDispatcher) { + try { + StampRead.Known(packages.lastUpdateTime(packageName)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.warn("lastUpdateTime lookup for {} failed", packageName, e) + StampRead.Unknown + } + } + + /** + * Polls until the package's lastUpdateTime moves off the stamp it had before the install. * * @param packageName the applicationId to watch - * @param initialStamp the stamp read before the install started; null means the package - * was absent, so any stamp at all counts as the change + * @param initialStamp what the pre-install read found. Absent means any stamp at all is + * the change. [StampRead.Unknown] means the baseline is still to be established: the + * first successful read becomes it and only a later change counts, so a failed read can + * never match against null. An install that lands before that baseline read is then + * settled by the broadcast alone; on an installer stack that never broadcasts it times + * out as retryable, which beats reporting a success the device did not perform. */ private suspend fun awaitStampChange( packageName: String, - initialStamp: Long?, + initialStamp: StampRead, ) { + var baseline = initialStamp while (true) { - val stamp = readPackages { packages.lastUpdateTime(packageName) } - if (stamp != null && stamp != initialStamp) return + val read = readStamp(packageName) + when (baseline) { + StampRead.Unknown -> { + baseline = read + } + + is StampRead.Known -> { + if (read is StampRead.Known && read.stamp != null && read.stamp != baseline.stamp) return + } + } delay(DEFAULT_POLL_MILLIS) } } diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt index 1e900d21d7..9051953980 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt @@ -46,8 +46,15 @@ class ProxyAppInstallerTest { return uid } + /** Makes the NEXT stamp lookup alone throw - one transient PackageManager fault. */ + var stampThrowsOnce = false + override fun lastUpdateTime(packageName: String): Long? { check(!lookupsThrow) { "lastUpdateTime lookup failed" } + if (stampThrowsOnce) { + stampThrowsOnce = false + error("lastUpdateTime lookup failed once") + } return stamp } @@ -657,4 +664,34 @@ class ProxyAppInstallerTest { .isEqualTo(ProxyAppInstaller.sha256OrNull(File(dir, "copy.apk").apply { writeText("apk-bytes-v1") })) assertThat(ProxyAppInstaller.sha256OrNull(File(dir, "missing.apk"))).isNull() } + + /** + * A throw and an absence must stay distinguishable on the pre-install stamp read. Folded + * into one null, the poll's first pass reads the OLD stamp of an installed package as "any + * stamp counts", and reports an install that never ran - the session then assembles onto + * a baseline the device is not running. + */ + @Test + fun `a transient stamp-read failure before the install does not read the old stamp as the change`() = + runTest { + packages.uid = 10123 + packages.stamp = 111L + packages.installedApk = File(dir, "installed.apk").apply { writeText("apk-bytes-v0") } + packages.stampThrowsOnce = true + + val result = async { installer(timeoutMillis = 30_000L).ensureInstalled(apk, PKG) } + runCurrent() + assertThat(installLaunches).containsExactly(apk) + advanceTimeBy(5_000L) + runCurrent() + + // Still waiting: the pre-existing install must not read as completion. + assertThat(result.isCompleted).isFalse() + + packages.stamp = 444L + advanceTimeBy(2_000L) + runCurrent() + + assertThat(result.await()).isEqualTo(InstallOutcome.Installed(10123)) + } } From d83beb00615a89adcaef57deaf75aa37fcb1dae9 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:50:44 -0700 Subject: [PATCH 16/26] ADFA-4128: make pendingUserActionSeen an AtomicBoolean Written in the broadcast collector and read on the parent while that child is still running, a captured var had no happens-before edge between the two. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3934045950 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../quickbuild/service/provision/ProxyAppInstaller.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt index 538470bf46..3c73e43cd3 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt @@ -16,6 +16,7 @@ import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage import org.slf4j.LoggerFactory import java.io.File import java.security.MessageDigest +import java.util.concurrent.atomic.AtomicBoolean /** * What the installer needs to know about installed packages; implemented over @@ -209,8 +210,10 @@ class ProxyAppInstaller( return coroutineScope { // Set when the OS reported PENDING_USER_ACTION, which means a confirm dialog - // exists; re-issuing the prompt then would stack a second dialog on it. - var pendingUserActionSeen = false + // exists; re-issuing the prompt then would stack a second dialog on it. Atomic + // because the write happens in the collector child and the read on this + // coroutine while that child is still running, so nothing else orders them. + val pendingUserActionSeen = AtomicBoolean(false) // Subscribe before committing the install so a fast broadcast cannot slip // past us. PENDING_USER_ACTION is decisive too when no confirm dialog can be // launched, since nobody will ever tap. @@ -222,7 +225,7 @@ class ProxyAppInstaller( val ours = broadcast.packageName == null || broadcast.packageName == packageName if (ours && broadcast.status == InstallBroadcast.Status.PENDING_USER_ACTION) { - pendingUserActionSeen = true + pendingUserActionSeen.set(true) } ours && ( @@ -286,7 +289,7 @@ class ProxyAppInstaller( // exists - the user is reading it, and a re-commit would put a // second dialog over the first. Only the silent case (no status // at all) is the lost-prompt one the re-issue repairs. - if (canShowConfirmDialog() && !pendingUserActionSeen) { + if (canShowConfirmDialog() && !pendingUserActionSeen.get()) { log.info( "no install verdict for {} in {}ms; re-issuing the prompt", packageName, From 592df0113d000b109fb0084f8a01fc4870f80e7b Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:50:44 -0700 Subject: [PATCH 17/26] ADFA-4128: expose quickbuild:protocol as an api dependency of core CompileOutput.stats, DexOutput.stats and DaemonReply.BuildFailed.stats put protocol types on core's public surface while the dependency was implementation, so a consumer could not read them. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3934045951 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- quickbuild/core/build.gradle.kts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/quickbuild/core/build.gradle.kts b/quickbuild/core/build.gradle.kts index 7a815d5632..0999389124 100644 --- a/quickbuild/core/build.gradle.kts +++ b/quickbuild/core/build.gradle.kts @@ -62,8 +62,10 @@ tasks.register("jacocoTestReport") { dependencies { implementation(projects.logger) implementation(projects.eventbusEvents) - // Wire DTOs/constants shared with the daemon (single protocol definition). - implementation(projects.quickbuild.protocol) + // Wire DTOs/constants shared with the daemon (single protocol definition). api, not + // implementation: CompileOutput.stats, DexOutput.stats and DaemonReply.BuildFailed.stats + // put its types on this module's public surface. + api(projects.quickbuild.protocol) implementation(libs.common.kotlin.coroutines.android) implementation(libs.google.gson) From b7c50dfef3583409bca96bb35190922300978b8a Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:50:45 -0700 Subject: [PATCH 18/26] ADFA-4128: suspend the generation store and scratch tree on an injected dispatcher FileGenerationStore and QuickBuildScratch did their file I/O inline, and both are called from the session thread that concurrency.md says must not block - the store on FUSE-backed project storage, the scratch sweep a deleteRecursively per leftover tree. Both now suspend and hop to an injected I/O dispatcher, as ProxyAppInstaller and QuickBuildClobberCheck already do. GenerationStore's contract follows, and GenerationTracker reads it through a suspend open() instead of in its constructor. The save KDoc's throws sentence now also covers the staged write, which sits outside the rename fallback. Callers on the orchestration branch (ProxyAppBuildRunner, QuickBuildSessionManager) adapt at the restack. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3934045941 https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3934045968 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../quickbuild/data/FileGenerationStore.kt | 74 +++--- .../cotg/quickbuild/data/QuickBuildScratch.kt | 75 +++--- .../domain/reload/GenerationTracker.kt | 32 ++- .../data/FileGenerationStoreEdgeTest.kt | 123 ++++++---- .../data/FileGenerationStoreTest.kt | 116 +++++---- .../data/QuickBuildScratchEdgeTest.kt | 85 +++++-- .../quickbuild/data/QuickBuildScratchTest.kt | 231 +++++++++--------- .../domain/reload/GenerationTrackerTest.kt | 123 +++++----- .../cotg/quickbuild/service/Fakes.kt | 4 +- .../deploy/PayloadDeployerRetentionTest.kt | 6 +- .../service/deploy/PayloadDeployerTest.kt | 2 +- quickbuild/docs/concurrency.md | 2 +- 12 files changed, 514 insertions(+), 359 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt index 9a61b22830..2481780b07 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt @@ -1,5 +1,8 @@ package org.appdevforall.cotg.quickbuild.data +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.appdevforall.cotg.quickbuild.domain.reload.GenerationStore import org.slf4j.LoggerFactory import java.io.File @@ -14,11 +17,17 @@ import java.io.IOException * later session stay strictly newer. A corrupt or unreadable file loads as null (fresh * session), so a broken state file cannot take quick build down. * + * Every read and write runs under [ioDispatcher]: the file sits under the project root on + * FUSE-backed storage, and the callers are on the session thread that concurrency.md says + * must not block. + * * @property file the counter file; it need not exist yet, its parent directory is created on * first [save], and a sibling `.tmp` is the write staging path. + * @property ioDispatcher where the file I/O runs; injectable so tests can pin the hop. */ class FileGenerationStore( private val file: File, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : GenerationStore { /** * Reads the persisted counter. @@ -26,12 +35,14 @@ class FileGenerationStore( * @return the stored generation, or null when the file is missing, unreadable, or does not * parse as a Long - all of which the caller treats as a fresh session. */ - override fun load(): Long? = - try { - if (file.isFile) file.readText().trim().toLongOrNull() else null - } catch (e: IOException) { - log.warn("Failed to read generation from {}; starting fresh", file, e) - null + override suspend fun load(): Long? = + withContext(ioDispatcher) { + try { + if (file.isFile) file.readText().trim().toLongOrNull() else null + } catch (e: IOException) { + log.warn("Failed to read generation from {}; starting fresh", file, e) + null + } } /** @@ -39,33 +50,34 @@ class FileGenerationStore( * * @param generation the value to store; the caller guarantees it is strictly greater than * any previously saved one, since the installed proxy app keys its payloads by it. - * @throws IOException when the value could not be persisted by any means - both renames - * AND the direct-write fallback failed; unlike [load] this is never swallowed, since - * losing it would let a later session reuse a generation. + * @throws IOException when the value could not be persisted: the staged write failed + * before any rename was tried, or both renames AND the direct-write fallback failed. + * Unlike [load] this is never swallowed, since losing it would let a later session + * reuse a generation. */ - override fun save(generation: Long) { - file.parentFile?.mkdirs() - val tmp = File(file.parentFile, file.name + ".tmp") - tmp.writeText(generation.toString()) - if (!tmp.renameTo(file)) { - // Windows-style rename-over-existing failure path; harmless on device but - // keeps the store correct wherever the JVM tests run. - file.delete() + override suspend fun save(generation: Long) = + withContext(ioDispatcher) { + file.parentFile?.mkdirs() + val tmp = File(file.parentFile, file.name + ".tmp") + tmp.writeText(generation.toString()) if (!tmp.renameTo(file)) { - // The old value is already deleted, so a bare throw here would leave NO - // counter at all - the next load() would restart the sequence, the exact - // reuse the class exists to rule out. Non-atomic beats lost. - try { - file.writeText(generation.toString()) - } catch (e: IOException) { - throw IOException("Unable to persist generation $generation to $file", e) - } finally { - tmp.delete() + // Windows-style rename-over-existing failure path; harmless on device but + // keeps the store correct wherever the JVM tests run. + file.delete() + if (!tmp.renameTo(file)) { + // The old value is already deleted, so a bare throw here would leave NO + // counter at all - the next load() would restart the sequence, the exact + // reuse the class exists to rule out. Non-atomic beats lost. + try { + file.writeText(generation.toString()) + } catch (e: IOException) { + throw IOException("Unable to persist generation $generation to $file", e) + } finally { + tmp.delete() + } } - return } } - } companion object { private val log = LoggerFactory.getLogger("QB-GenerationStore") @@ -75,8 +87,12 @@ class FileGenerationStore( * * @param projectRoot the user project's root directory; the file lands at * `.androidide/quickbuild/generation` beneath it, and neither need exist yet. + * @param ioDispatcher where the file I/O runs; see the class KDoc. * @return a store for that path; no filesystem access happens until [load] or [save]. */ - fun forProject(projectRoot: File): FileGenerationStore = FileGenerationStore(File(projectRoot, ".androidide/quickbuild/generation")) + fun forProject( + projectRoot: File, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + ): FileGenerationStore = FileGenerationStore(File(projectRoot, ".androidide/quickbuild/generation"), ioDispatcher) } } diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt index 98e1f4d237..14bbaf6f8e 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt @@ -1,5 +1,8 @@ package org.appdevforall.cotg.quickbuild.data +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage import org.slf4j.LoggerFactory import java.io.File @@ -13,14 +16,20 @@ import java.security.MessageDigest * ~50x per file (ADFA-4930); user sources never move. A tree exists only while its session * does, and nothing in it needs to survive one. * + * The path accessors are arithmetic. Everything that touches the disk - [freeSpaceShortfall], + * [prepare], [remove], [sweep] - suspends and runs under [ioDispatcher], because the callers + * are on the session thread that concurrency.md says must not block. + * * @property root parent of every per-project tree, created on demand; must be on app-private * storage, since `/storage/emulated` gives up the whole point of this class. * @property minFreeBytes free-space floor in bytes that [freeSpaceShortfall] enforces on * [root]'s volume, injectable so tests can drive the shortfall path. + * @property ioDispatcher where the disk work runs; injectable so tests can pin the hop. */ class QuickBuildScratch( private val root: File, private val minFreeBytes: Long = DEFAULT_MIN_FREE_BYTES, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { /** Outcome of [prepare]: a usable tree, or a user-facing reason there is none. */ sealed interface Preparation { @@ -103,7 +112,10 @@ class QuickBuildScratch( * @return null when there is room, else the user-facing message to surface; creates [root] * as a side effect, since usable space cannot be read through a directory that is not there. */ - fun freeSpaceShortfall(): QuickBuildMessage? { + suspend fun freeSpaceShortfall(): QuickBuildMessage? = withContext(ioDispatcher) { freeSpaceShortfallBlocking() } + + /** The [freeSpaceShortfall] body, for callers already on [ioDispatcher]. */ + private fun freeSpaceShortfallBlocking(): QuickBuildMessage? { // An uncreatable root must not fall through to the space read: usableSpace on a // nonexistent path is 0, which would report "not enough storage" - the wrong // remedy on screen - for what is a permissions or path problem. @@ -127,14 +139,15 @@ class QuickBuildScratch( * @return [Preparation.Ready] with the tree, or [Preparation.Failed] on a space shortfall or * an unwritable location; an already-existing tree is reused, not cleared. */ - fun prepare(projectRoot: File): Preparation { - freeSpaceShortfall()?.let { return Preparation.Failed(it) } - val tree = treeFor(projectRoot) - if (!tree.isDirectory && !tree.mkdirs()) { - return Preparation.Failed(QuickBuildMessage.ScratchDirUnavailable(tree.absolutePath)) + suspend fun prepare(projectRoot: File): Preparation = + withContext(ioDispatcher) { + freeSpaceShortfallBlocking()?.let { return@withContext Preparation.Failed(it) } + val tree = treeFor(projectRoot) + if (!tree.isDirectory && !tree.mkdirs()) { + return@withContext Preparation.Failed(QuickBuildMessage.ScratchDirUnavailable(tree.absolutePath)) + } + Preparation.Ready(tree) } - return Preparation.Ready(tree) - } /** * Deletes the project's tree; a missing tree is a no-op. Session-teardown hook. @@ -145,18 +158,19 @@ class QuickBuildScratch( * @param projectRoot the project whose tree to delete; its own directory, and the generation * counter inside it, are untouched. */ - fun remove(projectRoot: File) { - val tree = treeFor(projectRoot) - // deleteRecursively() also returns false for a tree that was never there, which is a - // documented no-op - so the residue, not the return value alone, is the failure. - if (!tree.deleteRecursively() && tree.exists()) { - log.error( - "Quick Build: could not fully delete the scratch tree {}; the next session for " + - "this project reuses what is left, so its build may start from stale intermediates", - tree.absolutePath, - ) + suspend fun remove(projectRoot: File) = + withContext(ioDispatcher) { + val tree = treeFor(projectRoot) + // deleteRecursively() also returns false for a tree that was never there, which is a + // documented no-op - so the residue, not the return value alone, is the failure. + if (!tree.deleteRecursively() && tree.exists()) { + log.error( + "Quick Build: could not fully delete the scratch tree {}; the next session for " + + "this project reuses what is left, so its build may start from stale intermediates", + tree.absolutePath, + ) + } } - } /** * Reclaims every tree under [root]. Called only at session-manager start, when nothing is @@ -166,19 +180,20 @@ class QuickBuildScratch( * A running session's tree is [remove]d at its own teardown, which is why this needs no * spare-list: there is nothing live for it to protect. */ - fun sweep() { - root.listFiles()?.forEach { child -> - if (child.isDirectory && !child.deleteRecursively() && child.exists()) { - // Not fatal: nothing live depends on a leftover, and the project it belongs - // to gets the same reuse behaviour as any warm tree. Logged because a tree - // that never clears is disk this class promises to reclaim. - log.warn( - "Quick Build: could not reclaim the leftover scratch tree {}; it stays on disk", - child.absolutePath, - ) + suspend fun sweep() = + withContext(ioDispatcher) { + root.listFiles()?.forEach { child -> + if (child.isDirectory && !child.deleteRecursively() && child.exists()) { + // Not fatal: nothing live depends on a leftover, and the project it belongs + // to gets the same reuse behaviour as any warm tree. Logged because a tree + // that never clears is disk this class promises to reclaim. + log.warn( + "Quick Build: could not reclaim the leftover scratch tree {}; it stays on disk", + child.absolutePath, + ) + } } } - } companion object { private val log = LoggerFactory.getLogger("QB-Scratch") diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTracker.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTracker.kt index abe26b2359..bd648dd59f 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTracker.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTracker.kt @@ -3,6 +3,9 @@ package org.appdevforall.cotg.quickbuild.domain.reload /** * Persistence for the session's generation counter. Implementations live in the data * layer (a file under the project's `.androidide` state dir); tests use an in-memory fake. + * + * Both members suspend because the callers sit on the session thread, which must not block + * (concurrency.md), and the real store is a file on FUSE-backed storage. */ interface GenerationStore { /** @@ -11,7 +14,7 @@ interface GenerationStore { * @return the stored number, or null when no session has ever run for this project - an * unreadable store must also answer null, since a throw would fail session startup. */ - fun load(): Long? + suspend fun load(): Long? /** * Persists [generation] so it survives a CoGo restart. @@ -19,7 +22,7 @@ interface GenerationStore { * @param generation the number just allocated, written before it is handed out so that a * crash burns it rather than letting a later session reuse it. */ - fun save(generation: Long) + suspend fun save(generation: Long) } /** @@ -31,14 +34,19 @@ interface GenerationStore { * * Not thread-safe - call from the orchestrator's single-threaded context. * - * @param store where the counter survives a restart; read once at construction, so a store - * changed underneath a live tracker is not noticed. + * Built through [open], which does the one read of the store; the constructor takes the + * value so that no blocking read hides in construction. + * + * @param store where the counter survives a restart; read once by [open], so a store changed + * underneath a live tracker is not noticed. + * @param initial the generation [open] read, or 0 when the store had none. */ class GenerationTracker( private val store: GenerationStore, + initial: Long, ) { /** The most recently allocated generation; 0 before any session has run. */ - var current: Long = store.load() ?: 0L + var current: Long = initial private set /** @@ -47,7 +55,7 @@ class GenerationTracker( * @return the new [current], always strictly greater than the previous one; a failed save * propagates, so no number is handed out that the store did not accept. */ - fun next(): Long { + suspend fun next(): Long { val next = current + 1 store.save(next) current = next @@ -67,10 +75,20 @@ class GenerationTracker( * @param generation the stamped baseline generation; values at or below [current] are * no-ops, so an unstamped (0) baseline never moves the counter. */ - fun adoptAtLeast(generation: Long) { + suspend fun adoptAtLeast(generation: Long) { if (generation > current) { store.save(generation) current = generation } } + + companion object { + /** + * Reads the store once and builds a tracker resuming from it. + * + * @param store where the counter survives a restart. + * @return a tracker whose [current] is the stored generation, or 0 for a fresh project. + */ + suspend fun open(store: GenerationStore): GenerationTracker = GenerationTracker(store, store.load() ?: 0L) + } } diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreEdgeTest.kt index 4ea052229b..608fc132ed 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreEdgeTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreEdgeTest.kt @@ -1,11 +1,14 @@ package org.appdevforall.cotg.quickbuild.data import com.google.common.truth.Truth.assertThat -import org.junit.jupiter.api.Assertions.assertThrows +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.io.File import java.io.IOException +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors /** * The rename-fallback path of [FileGenerationStore.save] (delete-then-retry, for @@ -16,11 +19,12 @@ class FileGenerationStoreEdgeTest { @TempDir lateinit var tmp: File @Test - fun `a generation path that is a directory loads as null`() { - val dir = File(tmp, "generation").apply { mkdirs() } + fun `a generation path that is a directory loads as null`() = + runTest { + val dir = File(tmp, "generation").apply { mkdirs() } - assertThat(FileGenerationStore(dir).load()).isNull() - } + assertThat(FileGenerationStore(dir).load()).isNull() + } /** * Only a path that IS a file and still fails to open exercises the IOException guard, @@ -31,27 +35,29 @@ class FileGenerationStoreEdgeTest { * read bit, so the test would skip exactly where the guard matters. */ @Test - fun `a generation file that cannot be read starts fresh instead of throwing`() { - val unopenable = - object : File(tmp, "generation") { - override fun isFile(): Boolean = true - } + fun `a generation file that cannot be read starts fresh instead of throwing`() = + runTest { + val unopenable = + object : File(tmp, "generation") { + override fun isFile(): Boolean = true + } - assertThat(FileGenerationStore(unopenable).load()).isNull() - } + assertThat(FileGenerationStore(unopenable).load()).isNull() + } @Test - fun `save falls back to delete-then-rename when the direct rename is refused`() { - // An empty directory at the target defeats the direct rename (a file cannot - // rename over a directory) but can be deleted - the retry must then land. - val target = File(tmp, "generation").apply { mkdirs() } - val store = FileGenerationStore(target) + fun `save falls back to delete-then-rename when the direct rename is refused`() = + runTest { + // An empty directory at the target defeats the direct rename (a file cannot + // rename over a directory) but can be deleted - the retry must then land. + val target = File(tmp, "generation").apply { mkdirs() } + val store = FileGenerationStore(target) - store.save(42) + store.save(42) - assertThat(target.isFile).isTrue() - assertThat(store.load()).isEqualTo(42) - } + assertThat(target.isFile).isTrue() + assertThat(store.load()).isEqualTo(42) + } /** * The arm that keeps the counter when neither rename can land: the old value is already @@ -64,30 +70,63 @@ class FileGenerationStoreEdgeTest { * the value can survive. */ @Test - fun `save writes the counter directly when the retry rename cannot run`() { - val path = File(tmp, "generation").apply { mkdirs() } - val target = - object : File(path.absolutePath) { - override fun delete(): Boolean { - File(parentFile, "$name.tmp").delete() - return super.delete() + fun `save writes the counter directly when the retry rename cannot run`() = + runTest { + val path = File(tmp, "generation").apply { mkdirs() } + val target = + object : File(path.absolutePath) { + override fun delete(): Boolean { + File(parentFile, "$name.tmp").delete() + return super.delete() + } } - } - val store = FileGenerationStore(target) + val store = FileGenerationStore(target) - store.save(42) + store.save(42) - assertThat(FileGenerationStore(path).load()).isEqualTo(42) - } + assertThat(FileGenerationStore(path).load()).isEqualTo(42) + } @Test - fun `save throws when the target cannot be replaced at all`() { - // A NON-empty directory defeats both the rename and the delete; the store must - // say so rather than silently keep the old state. - val target = File(tmp, "generation").apply { mkdirs() } - File(target, "occupant.txt").writeText("in the way") - val store = FileGenerationStore(target) - - assertThrows(IOException::class.java) { store.save(42) } - } + fun `save throws when the target cannot be replaced at all`() = + runTest { + // A NON-empty directory defeats both the rename and the delete; the store must + // say so rather than silently keep the old state. + val target = File(tmp, "generation").apply { mkdirs() } + File(target, "occupant.txt").writeText("in the way") + val store = FileGenerationStore(target) + + assertThat(runCatching { store.save(42) }.exceptionOrNull()).isInstanceOf(IOException::class.java) + } + + /** + * The callers sit on the session thread, which must not block, so every disk touch has to + * run on the injected dispatcher. Pinned through the file object: [File.isFile] is the + * first call load makes and [File.getParentFile] the first save makes, so the thread each + * lands on is the thread the I/O ran on. + */ + @Test + fun `load and save run on the injected dispatcher, not the caller's thread`() = + runTest { + val ioThread = "qb-store-io-probe" + val executor = Executors.newSingleThreadExecutor { Thread(it, ioThread) } + val seen = CopyOnWriteArrayList() + val probed = + object : File(tmp, "generation") { + override fun isFile(): Boolean = super.isFile().also { seen += Thread.currentThread().name } + + override fun getParentFile(): File? = super.getParentFile().also { seen += Thread.currentThread().name } + } + try { + val store = FileGenerationStore(probed, executor.asCoroutineDispatcher()) + + store.save(9) + assertThat(store.load()).isEqualTo(9) + } finally { + executor.shutdown() + } + + assertThat(seen).isNotEmpty() + assertThat(seen.toSet()).containsExactly(ioThread) + } } diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt index b54f08ee6d..84f97f5f28 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt @@ -1,6 +1,7 @@ package org.appdevforall.cotg.quickbuild.data import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.io.File @@ -11,75 +12,84 @@ class FileGenerationStoreTest { private fun store(name: String = "generation") = FileGenerationStore(File(tempDir, name)) @Test - fun `round trips a generation`() { - val store = store() - store.save(42) - assertThat(store.load()).isEqualTo(42) - } + fun `round trips a generation`() = + runTest { + val store = store() + store.save(42) + assertThat(store.load()).isEqualTo(42) + } @Test - fun `missing file loads as null`() { - assertThat(store().load()).isNull() - } + fun `missing file loads as null`() = + runTest { + assertThat(store().load()).isNull() + } @Test - fun `corrupt file loads as null instead of throwing`() { - val file = File(tempDir, "generation") - file.writeText("not-a-number") - assertThat(FileGenerationStore(file).load()).isNull() - } + fun `corrupt file loads as null instead of throwing`() = + runTest { + val file = File(tempDir, "generation") + file.writeText("not-a-number") + assertThat(FileGenerationStore(file).load()).isNull() + } @Test - fun `empty file loads as null`() { - val file = File(tempDir, "generation") - file.writeText("") - assertThat(FileGenerationStore(file).load()).isNull() - } + fun `empty file loads as null`() = + runTest { + val file = File(tempDir, "generation") + file.writeText("") + assertThat(FileGenerationStore(file).load()).isNull() + } @Test - fun `save creates missing parent directories`() { - val file = File(tempDir, "nested/dirs/generation") - val store = FileGenerationStore(file) - store.save(7) - assertThat(file.readText().trim()).isEqualTo("7") - } + fun `save creates missing parent directories`() = + runTest { + val file = File(tempDir, "nested/dirs/generation") + val store = FileGenerationStore(file) + store.save(7) + assertThat(file.readText().trim()).isEqualTo("7") + } @Test - fun `save overwrites the previous value`() { - val store = store() - store.save(1) - store.save(2) - assertThat(store.load()).isEqualTo(2) - } + fun `save overwrites the previous value`() = + runTest { + val store = store() + store.save(1) + store.save(2) + assertThat(store.load()).isEqualTo(2) + } @Test - fun `whitespace around the number is tolerated`() { - val file = File(tempDir, "generation") - file.writeText(" 13\n") - assertThat(FileGenerationStore(file).load()).isEqualTo(13) - } + fun `whitespace around the number is tolerated`() = + runTest { + val file = File(tempDir, "generation") + file.writeText(" 13\n") + assertThat(FileGenerationStore(file).load()).isEqualTo(13) + } @Test - fun `a save that cannot replace the target cleans up its temp file when it throws`() { - // A non-empty directory squatting on the counter path defeats both renames AND the - // direct-write fallback; the save must still throw - the value genuinely could not - // be persisted - without leaving the .tmp orphan for the next load to trip on. - val target = File(tempDir, "generation") - target.mkdirs() - File(target, "occupant").writeText("x") + fun `a save that cannot replace the target cleans up its temp file when it throws`() = + runTest { + // A non-empty directory squatting on the counter path defeats both renames AND the + // direct-write fallback; the save must still throw - the value genuinely could not + // be persisted - without leaving the .tmp orphan for the next load to trip on. + val target = File(tempDir, "generation") + target.mkdirs() + File(target, "occupant").writeText("x") - val thrown = runCatching { FileGenerationStore(target).save(5) }.exceptionOrNull() + val thrown = runCatching { FileGenerationStore(target).save(5) }.exceptionOrNull() - assertThat(thrown).isInstanceOf(java.io.IOException::class.java) - assertThat(File(tempDir, "generation.tmp").exists()).isFalse() - } + assertThat(thrown).isInstanceOf(java.io.IOException::class.java) + assertThat(File(tempDir, "generation.tmp").exists()).isFalse() + } @Test - fun `forProject uses the canonical androidide state path`() { - val projectRoot = File(tempDir, "project") - val store = FileGenerationStore.forProject(projectRoot) - store.save(3) - assertThat(File(projectRoot, ".androidide/quickbuild/generation").readText().trim()) - .isEqualTo("3") - } + fun `forProject uses the canonical androidide state path`() = + runTest { + val projectRoot = File(tempDir, "project") + val store = FileGenerationStore.forProject(projectRoot) + store.save(3) + assertThat(File(projectRoot, ".androidide/quickbuild/generation").readText().trim()) + .isEqualTo("3") + } } diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchEdgeTest.kt index 2ce1a73f14..06bb7e306e 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchEdgeTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchEdgeTest.kt @@ -1,10 +1,14 @@ package org.appdevforall.cotg.quickbuild.data import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.test.runTest import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.io.File +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors /** * Key-sanitization and preparation edges of [QuickBuildScratch] beyond @@ -43,31 +47,66 @@ class QuickBuildScratchEdgeTest { } @Test - fun `prepare is idempotent on an existing tree`() { - val scratch = scratch() - val project = File(tmp, "proj").apply { mkdirs() } - val first = scratch.prepare(project) as QuickBuildScratch.Preparation.Ready - File(first.dir, "work").mkdirs() + fun `prepare is idempotent on an existing tree`() = + runTest { + val scratch = scratch() + val project = File(tmp, "proj").apply { mkdirs() } + val first = scratch.prepare(project) as QuickBuildScratch.Preparation.Ready + File(first.dir, "work").mkdirs() - val second = scratch.prepare(project) + val second = scratch.prepare(project) - // The existing tree (and anything in it) is kept, not recreated. - assertThat(second).isEqualTo(first) - assertThat(File(first.dir, "work").isDirectory).isTrue() - } + // The existing tree (and anything in it) is kept, not recreated. + assertThat(second).isEqualTo(first) + assertThat(File(first.dir, "work").isDirectory).isTrue() + } @Test - fun `a tree blocked by a stray file fails with the user-facing message`() { - val scratch = scratch() - val project = File(tmp, "proj").apply { mkdirs() } - val tree = scratch.treeFor(project) - tree.parentFile!!.mkdirs() - tree.writeText("not a directory") - - val preparation = scratch.prepare(project) - - assertThat(preparation).isInstanceOf(QuickBuildScratch.Preparation.Failed::class.java) - assertThat((preparation as QuickBuildScratch.Preparation.Failed).message) - .isInstanceOf(QuickBuildMessage.ScratchDirUnavailable::class.java) - } + fun `a tree blocked by a stray file fails with the user-facing message`() = + runTest { + val scratch = scratch() + val project = File(tmp, "proj").apply { mkdirs() } + val tree = scratch.treeFor(project) + tree.parentFile!!.mkdirs() + tree.writeText("not a directory") + + val preparation = scratch.prepare(project) + + assertThat(preparation).isInstanceOf(QuickBuildScratch.Preparation.Failed::class.java) + assertThat((preparation as QuickBuildScratch.Preparation.Failed).message) + .isInstanceOf(QuickBuildMessage.ScratchDirUnavailable::class.java) + } + + /** + * The callers sit on the session thread, which must not block, so every disk touch has to + * run on the injected dispatcher. Pinned through the root: [File.isDirectory] is the first + * call the space guard makes and [File.listFiles] the first sweep makes. + */ + @Test + fun `disk work runs on the injected dispatcher, not the caller's thread`() = + runTest { + val ioThread = "qb-scratch-io-probe" + val executor = Executors.newSingleThreadExecutor { Thread(it, ioThread) } + val seen = CopyOnWriteArrayList() + val probedRoot = + object : File(tmp, "scratch-root") { + override fun isDirectory(): Boolean = super.isDirectory().also { seen += Thread.currentThread().name } + + override fun listFiles(): Array? = super.listFiles().also { seen += Thread.currentThread().name } + } + try { + val probed = QuickBuildScratch(probedRoot, ioDispatcher = executor.asCoroutineDispatcher()) + val project = File(tmp, "proj").apply { mkdirs() } + + assertThat(probed.freeSpaceShortfall()).isNull() + assertThat(probed.prepare(project)).isInstanceOf(QuickBuildScratch.Preparation.Ready::class.java) + probed.remove(project) + probed.sweep() + } finally { + executor.shutdown() + } + + assertThat(seen).isNotEmpty() + assertThat(seen.toSet()).containsExactly(ioThread) + } } diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchTest.kt index c683c7d390..15d85fc8c1 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratchTest.kt @@ -1,6 +1,7 @@ package org.appdevforall.cotg.quickbuild.data import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.Test @@ -61,107 +62,115 @@ class QuickBuildScratchTest { } @Test - fun `prepare creates the tree and reports ready`() { - val project = File(projects, "MyApp") + fun `prepare creates the tree and reports ready`() = + runTest { + val project = File(projects, "MyApp") - val prepared = scratch.prepare(project) + val prepared = scratch.prepare(project) - assertThat(prepared).isInstanceOf(QuickBuildScratch.Preparation.Ready::class.java) - assertThat((prepared as QuickBuildScratch.Preparation.Ready).dir.isDirectory).isTrue() - assertThat(prepared.dir).isEqualTo(scratch.treeFor(project)) - } + assertThat(prepared).isInstanceOf(QuickBuildScratch.Preparation.Ready::class.java) + assertThat((prepared as QuickBuildScratch.Preparation.Ready).dir.isDirectory).isTrue() + assertThat(prepared.dir).isEqualTo(scratch.treeFor(project)) + } @Test - fun `prepare fails with a user-facing message when the volume is below the floor`() { - // A floor no real filesystem satisfies forces the shortfall branch. - val guarded = QuickBuildScratch(root, minFreeBytes = Long.MAX_VALUE) - - val prepared = guarded.prepare(File(projects, "MyApp")) - - assertThat(prepared).isInstanceOf(QuickBuildScratch.Preparation.Failed::class.java) - // Named, with the two numbers the host's copy interpolates - the wording itself - // lives in the app module's resources. - val message = (prepared as QuickBuildScratch.Preparation.Failed).message - assertThat(message).isInstanceOf(QuickBuildMessage.NotEnoughStorage::class.java) - assertThat((message as QuickBuildMessage.NotEnoughStorage).requiredMb).isGreaterThan(0L) - // The failure never half-creates the tree. - assertThat(guarded.treeFor(File(projects, "MyApp")).exists()).isFalse() - } + fun `prepare fails with a user-facing message when the volume is below the floor`() = + runTest { + // A floor no real filesystem satisfies forces the shortfall branch. + val guarded = QuickBuildScratch(root, minFreeBytes = Long.MAX_VALUE) + + val prepared = guarded.prepare(File(projects, "MyApp")) + + assertThat(prepared).isInstanceOf(QuickBuildScratch.Preparation.Failed::class.java) + // Named, with the two numbers the host's copy interpolates - the wording itself + // lives in the app module's resources. + val message = (prepared as QuickBuildScratch.Preparation.Failed).message + assertThat(message).isInstanceOf(QuickBuildMessage.NotEnoughStorage::class.java) + assertThat((message as QuickBuildMessage.NotEnoughStorage).requiredMb).isGreaterThan(0L) + // The failure never half-creates the tree. + assertThat(guarded.treeFor(File(projects, "MyApp")).exists()).isFalse() + } @Test - fun `freeSpaceShortfall is null when the volume has room`() { - assertThat(scratch.freeSpaceShortfall()).isNull() - } + fun `freeSpaceShortfall is null when the volume has room`() = + runTest { + assertThat(scratch.freeSpaceShortfall()).isNull() + } @Test - fun `an uncreatable root reports ScratchDirUnavailable, not a storage shortfall`() { - // usableSpace on a nonexistent path is 0, so without its own guard an uncreatable - // root would read as "not enough storage" - the wrong remedy on screen - when the - // real problem is the path. - val blocker = File(root, "blocker").apply { writeText("a file, not a dir") } - val blocked = QuickBuildScratch(File(blocker, "scratch")) + fun `an uncreatable root reports ScratchDirUnavailable, not a storage shortfall`() = + runTest { + // usableSpace on a nonexistent path is 0, so without its own guard an uncreatable + // root would read as "not enough storage" - the wrong remedy on screen - when the + // real problem is the path. + val blocker = File(root, "blocker").apply { writeText("a file, not a dir") } + val blocked = QuickBuildScratch(File(blocker, "scratch")) - val message = blocked.freeSpaceShortfall() + val message = blocked.freeSpaceShortfall() - assertThat(message).isInstanceOf(QuickBuildMessage.ScratchDirUnavailable::class.java) - } + assertThat(message).isInstanceOf(QuickBuildMessage.ScratchDirUnavailable::class.java) + } @Test - fun `remove deletes the tree and tolerates a missing one`() { - val project = File(projects, "MyApp") - val tree = (scratch.prepare(project) as QuickBuildScratch.Preparation.Ready).dir - File(tree, "out/classes/Foo.class").apply { - parentFile!!.mkdirs() - writeText("bytecode") + fun `remove deletes the tree and tolerates a missing one`() = + runTest { + val project = File(projects, "MyApp") + val tree = (scratch.prepare(project) as QuickBuildScratch.Preparation.Ready).dir + File(tree, "out/classes/Foo.class").apply { + parentFile!!.mkdirs() + writeText("bytecode") + } + + scratch.remove(project) + assertThat(tree.exists()).isFalse() + + // Second remove: nothing there, nothing thrown. + scratch.remove(project) } - scratch.remove(project) - assertThat(tree.exists()).isFalse() - - // Second remove: nothing there, nothing thrown. - scratch.remove(project) - } - @Test - fun `sweep removes every tree, including a populated one`() { - val first = File(projects, "FirstApp") - val second = File(projects, "SecondApp") - val firstTree = (scratch.prepare(first) as QuickBuildScratch.Preparation.Ready).dir - val secondTree = (scratch.prepare(second) as QuickBuildScratch.Preparation.Ready).dir - File(secondTree, "out/stale.dex").apply { - parentFile!!.mkdirs() - writeText("stale") + fun `sweep removes every tree, including a populated one`() = + runTest { + val first = File(projects, "FirstApp") + val second = File(projects, "SecondApp") + val firstTree = (scratch.prepare(first) as QuickBuildScratch.Preparation.Ready).dir + val secondTree = (scratch.prepare(second) as QuickBuildScratch.Preparation.Ready).dir + File(secondTree, "out/stale.dex").apply { + parentFile!!.mkdirs() + writeText("stale") + } + + scratch.sweep() + + assertThat(firstTree.exists()).isFalse() + assertThat(secondTree.exists()).isFalse() } - scratch.sweep() - - assertThat(firstTree.exists()).isFalse() - assertThat(secondTree.exists()).isFalse() - } - @Test - fun `sweep reclaims the tree of a deleted project`() { - val project = File(projects, "Doomed").apply { mkdirs() } - val tree = (scratch.prepare(project) as QuickBuildScratch.Preparation.Ready).dir + fun `sweep reclaims the tree of a deleted project`() = + runTest { + val project = File(projects, "Doomed").apply { mkdirs() } + val tree = (scratch.prepare(project) as QuickBuildScratch.Preparation.Ready).dir - // The project folder is gone; only the key (derived from the path string) - // remains - the sweep must still find and delete the orphan tree. - project.deleteRecursively() - scratch.sweep() + // The project folder is gone; only the key (derived from the path string) + // remains - the sweep must still find and delete the orphan tree. + project.deleteRecursively() + scratch.sweep() - assertThat(tree.exists()).isFalse() - } + assertThat(tree.exists()).isFalse() + } @Test - fun `sweep leaves stray files and tolerates a missing root`() { - val stray = File(root, "not-a-tree.txt").apply { writeText("keep me") } - scratch.sweep() - assertThat(stray.exists()).isTrue() - - root.deleteRecursively() - // Missing root: listFiles() is null; nothing thrown. - scratch.sweep() - } + fun `sweep leaves stray files and tolerates a missing root`() = + runTest { + val stray = File(root, "not-a-tree.txt").apply { writeText("keep me") } + scratch.sweep() + assertThat(stray.exists()).isTrue() + + root.deleteRecursively() + // Missing root: listFiles() is null; nothing thrown. + scratch.sweep() + } /** * Pins [dir] shut by clearing its write bit, so nothing inside it can be unlinked and @@ -177,37 +186,39 @@ class QuickBuildScratchTest { } @Test - fun `remove reports an undeletable tree instead of throwing`() { - val project = File(projects, "Stuck") - val tree = (scratch.prepare(project) as QuickBuildScratch.Preparation.Ready).dir - val out = File(tree, "out").apply { mkdirs() } - val pinned = File(out, "pinned.class").apply { writeText("bytecode") } - pinShut(out) - - // Teardown has to finish, so a tree that will not go is logged, never propagated. - scratch.remove(project) - - assertThat(pinned.exists()).isTrue() - out.setWritable(true) - } + fun `remove reports an undeletable tree instead of throwing`() = + runTest { + val project = File(projects, "Stuck") + val tree = (scratch.prepare(project) as QuickBuildScratch.Preparation.Ready).dir + val out = File(tree, "out").apply { mkdirs() } + val pinned = File(out, "pinned.class").apply { writeText("bytecode") } + pinShut(out) + + // Teardown has to finish, so a tree that will not go is logged, never propagated. + scratch.remove(project) + + assertThat(pinned.exists()).isTrue() + out.setWritable(true) + } @Test - fun `sweep keeps reclaiming past a tree it cannot delete`() { - val stuckTree = - (scratch.prepare(File(projects, "Stuck")) as QuickBuildScratch.Preparation.Ready).dir - val healthyTree = - (scratch.prepare(File(projects, "Healthy")) as QuickBuildScratch.Preparation.Ready).dir - val out = File(stuckTree, "out").apply { mkdirs() } - val pinned = File(out, "pinned.class").apply { writeText("bytecode") } - pinShut(out) - - scratch.sweep() - - // A stuck tree costs its own disk and nothing else. Note this pins the OUTCOME, not - // the iteration order: listFiles() decides which tree is visited first, so a sweep - // that aborted on the failure would still pass whenever the stuck tree came last. - assertThat(pinned.exists()).isTrue() - assertThat(healthyTree.exists()).isFalse() - out.setWritable(true) - } + fun `sweep keeps reclaiming past a tree it cannot delete`() = + runTest { + val stuckTree = + (scratch.prepare(File(projects, "Stuck")) as QuickBuildScratch.Preparation.Ready).dir + val healthyTree = + (scratch.prepare(File(projects, "Healthy")) as QuickBuildScratch.Preparation.Ready).dir + val out = File(stuckTree, "out").apply { mkdirs() } + val pinned = File(out, "pinned.class").apply { writeText("bytecode") } + pinShut(out) + + scratch.sweep() + + // A stuck tree costs its own disk and nothing else. Note this pins the OUTCOME, not + // the iteration order: listFiles() decides which tree is visited first, so a sweep + // that aborted on the failure would still pass whenever the stuck tree came last. + assertThat(pinned.exists()).isTrue() + assertThat(healthyTree.exists()).isFalse() + out.setWritable(true) + } } diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTrackerTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTrackerTest.kt index b47b5f452e..43abf167da 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTrackerTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTrackerTest.kt @@ -1,6 +1,7 @@ package org.appdevforall.cotg.quickbuild.domain.reload import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test class GenerationTrackerTest { @@ -9,88 +10,94 @@ class GenerationTrackerTest { ) : GenerationStore { val saves: MutableList = mutableListOf() - override fun load(): Long? = stored + override suspend fun load(): Long? = stored - override fun save(generation: Long) { + override suspend fun save(generation: Long) { saves.add(generation) stored = generation } } @Test - fun `fresh store starts at generation 0 and next returns 1`() { - val store = FakeStore() - val tracker = GenerationTracker(store) + fun `fresh store starts at generation 0 and next returns 1`() = + runTest { + val store = FakeStore() + val tracker = GenerationTracker.open(store) - assertThat(tracker.current).isEqualTo(0L) + assertThat(tracker.current).isEqualTo(0L) - val next = tracker.next() + val next = tracker.next() - assertThat(next).isEqualTo(1L) - assertThat(store.saves).isEqualTo(listOf(1L)) - } + assertThat(next).isEqualTo(1L) + assertThat(store.saves).isEqualTo(listOf(1L)) + } @Test - fun `next is monotonic across calls`() { - val store = FakeStore() - val tracker = GenerationTracker(store) + fun `next is monotonic across calls`() = + runTest { + val store = FakeStore() + val tracker = GenerationTracker.open(store) - assertThat(tracker.next()).isEqualTo(1L) - assertThat(tracker.current).isEqualTo(1L) + assertThat(tracker.next()).isEqualTo(1L) + assertThat(tracker.current).isEqualTo(1L) - assertThat(tracker.next()).isEqualTo(2L) - assertThat(tracker.current).isEqualTo(2L) + assertThat(tracker.next()).isEqualTo(2L) + assertThat(tracker.current).isEqualTo(2L) - assertThat(tracker.next()).isEqualTo(3L) - assertThat(tracker.current).isEqualTo(3L) - } + assertThat(tracker.next()).isEqualTo(3L) + assertThat(tracker.current).isEqualTo(3L) + } @Test - fun `resumes from a store with an existing generation`() { - val store = FakeStore(stored = 41L) - val tracker = GenerationTracker(store) + fun `resumes from a store with an existing generation`() = + runTest { + val store = FakeStore(stored = 41L) + val tracker = GenerationTracker.open(store) - assertThat(tracker.current).isEqualTo(41L) - assertThat(tracker.next()).isEqualTo(42L) - } + assertThat(tracker.current).isEqualTo(41L) + assertThat(tracker.next()).isEqualTo(42L) + } @Test - fun `persists before next returns`() { - val store = FakeStore() - val tracker = GenerationTracker(store) + fun `persists before next returns`() = + runTest { + val store = FakeStore() + val tracker = GenerationTracker.open(store) - val next = tracker.next() + val next = tracker.next() - assertThat(next).isEqualTo(1L) - assertThat(store.saves).isEqualTo(listOf(1L)) - } + assertThat(next).isEqualTo(1L) + assertThat(store.saves).isEqualTo(listOf(1L)) + } @Test - fun `adoptAtLeast moves the counter past a stamped baseline and persists it`() { - // A rebaseline stamps generation 8 through the host-side allocator while this - // (session) tracker still sits at 7; without adoption the next deploy would be 8, - // equal to the baseline, and the runtime would reject it as stale. - val store = FakeStore(stored = 7L) - val tracker = GenerationTracker(store) - - tracker.adoptAtLeast(8L) - - assertThat(tracker.current).isEqualTo(8L) - assertThat(store.saves).isEqualTo(listOf(8L)) - assertThat(tracker.next()).isEqualTo(9L) - } + fun `adoptAtLeast moves the counter past a stamped baseline and persists it`() = + runTest { + // A rebaseline stamps generation 8 through the host-side allocator while this + // (session) tracker still sits at 7; without adoption the next deploy would be 8, + // equal to the baseline, and the runtime would reject it as stale. + val store = FakeStore(stored = 7L) + val tracker = GenerationTracker.open(store) + + tracker.adoptAtLeast(8L) + + assertThat(tracker.current).isEqualTo(8L) + assertThat(store.saves).isEqualTo(listOf(8L)) + assertThat(tracker.next()).isEqualTo(9L) + } @Test - fun `adoptAtLeast is a no-op at or below the current counter`() { - val store = FakeStore(stored = 5L) - val tracker = GenerationTracker(store) - - // An unstamped (0) baseline and a stale stamp must not move or re-save the counter. - tracker.adoptAtLeast(0L) - tracker.adoptAtLeast(5L) - - assertThat(tracker.current).isEqualTo(5L) - assertThat(store.saves).isEmpty() - assertThat(tracker.next()).isEqualTo(6L) - } + fun `adoptAtLeast is a no-op at or below the current counter`() = + runTest { + val store = FakeStore(stored = 5L) + val tracker = GenerationTracker.open(store) + + // An unstamped (0) baseline and a stale stamp must not move or re-save the counter. + tracker.adoptAtLeast(0L) + tracker.adoptAtLeast(5L) + + assertThat(tracker.current).isEqualTo(5L) + assertThat(store.saves).isEmpty() + assertThat(tracker.next()).isEqualTo(6L) + } } diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt index f737b85a32..78afb8d313 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt @@ -188,9 +188,9 @@ class FakeDeploy : DeploySender { class MemoryGenerationStore : GenerationStore { var value: Long? = null - override fun load(): Long? = value + override suspend fun load(): Long? = value - override fun save(generation: Long) { + override suspend fun save(generation: Long) { value = generation } } diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerRetentionTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerRetentionTest.kt index 6deaa2bc57..99333bd9b3 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerRetentionTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerRetentionTest.kt @@ -30,7 +30,7 @@ class PayloadDeployerRetentionTest { private fun deployer() = PayloadDeployer( deploy = deploy, - generations = GenerationTracker(MemoryGenerationStore()), + generations = GenerationTracker(MemoryGenerationStore(), initial = 0L), entryActivity = "com.example.app.MainActivity", proxyAppPackage = "com.example.app", launcherActivity = "com.example.app.Proxy0Activity", @@ -162,7 +162,7 @@ class PayloadDeployerRetentionTest { val deployer = PayloadDeployer( deploy = deploy, - generations = GenerationTracker(MemoryGenerationStore()), + generations = GenerationTracker(MemoryGenerationStore(), initial = 0L), entryActivity = "com.example.app.MainActivity", proxyAppPackage = "com.example.app", launcherActivity = "com.example.app.Proxy0Activity", @@ -200,7 +200,7 @@ class PayloadDeployerRetentionTest { val deployer = PayloadDeployer( deploy = deploy, - generations = GenerationTracker(MemoryGenerationStore()), + generations = GenerationTracker(MemoryGenerationStore(), initial = 0L), entryActivity = "com.example.app.MainActivity", proxyAppPackage = "com.example.app", launcherActivity = "com.example.app.Proxy0Activity", diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerTest.kt index 03ba73bbc0..4d6aee2d06 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerTest.kt @@ -25,7 +25,7 @@ class PayloadDeployerTest { userInitiated: Boolean = true, ) = PayloadDeployer( deploy = deploy, - generations = GenerationTracker(MemoryGenerationStore()), + generations = GenerationTracker(MemoryGenerationStore(), initial = 0L), entryActivity = "com.example.app.MainActivity", proxyAppPackage = proxyAppPackage, launcherActivity = "com.example.app.Proxy0Activity", diff --git a/quickbuild/docs/concurrency.md b/quickbuild/docs/concurrency.md index ab1e231a75..e33472e4f2 100644 --- a/quickbuild/docs/concurrency.md +++ b/quickbuild/docs/concurrency.md @@ -51,7 +51,7 @@ Every dotted edge is a result **hopping back onto the session thread**. Nothing - **Effects are launched, not run inline.** `runEffect` launches each effect so a dispatch can never re-enter itself; the launches still land in order because there is one thread. Swap in `Dispatchers.IO` and ordering breaks with no crash and no failing test (README, invariant 3 of [Areas to Be Careful Of](../README.md#areas-to-be-careful-of)). - **The reducer is total.** An unhandled `(state, event)` pair keeps the state and produces no effects ([`SessionReducer`](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/SessionReducer.kt)), so a late, duplicate or out-of-order event is a no-op rather than a corrupt session. Every guard below can therefore be "drop it" instead of "unwind it". -- **Nothing on that thread may block.** Every outward call is `suspend`; the daemon client hops its process I/O to `Dispatchers.IO` and the watcher runs its stat sweep there. A blocking call added here stalls the whole session. +- **Nothing on that thread may block.** Every outward call is `suspend`; the daemon client hops its process I/O to `Dispatchers.IO`, the watcher runs its stat sweep there, and the file-touching helpers (`ProxyAppInstaller`, `QuickBuildClobberCheck`, `QuickBuildScratch`, `FileGenerationStore` and the `GenerationTracker` over it) take an injected I/O dispatcher for the same reason. A blocking call added here stalls the whole session. **What is farmed out, and how results come back.** The session thread never compiles anything. Each build is one suspending pass through [`LiveReloadExecutorImpl`](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImpl.kt) - compile, dex, relink, deploy, strictly in order, each step one request to the daemon. The daemon holds **one request in flight** (`requestMutex`) and its own loop is single-threaded on purpose, so the pipeline is serial end to end; a request that exceeds `requestTimeoutMillis` (300 s) comes back as a failed reply rather than an exception. Results re-enter the model three ways, all of them hopping back onto the session thread: the executor's return value becomes an `OrchestratorEvent`, which the orchestrator delivers *outside* its own lock and the manager `launch`es into a dispatch; the proxy app's crash and reconnect reports arrive as flows collected on the session scope; the daemon's death arrives as a listener callback that dispatches `DaemonDied`. From 6727ef948716b9763d5771a209f0cd290a220dec Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:50:45 -0700 Subject: [PATCH 19/26] ADFA-4128: name the layout walkers and the scratch residue a failed provision leaves QuickBuildProjectLayout's off-main-thread warning named the private moduleDirs instead of the public watchedRoots and watchedFiles that call it. QuickBuildScratch.prepare now states that a provision failing after it returns leaves the tree for the next sweep, tracked as a followup under ADFA-5423. https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3934045974 https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3934045957 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../cotg/quickbuild/data/QuickBuildProjectLayout.kt | 5 +++-- .../appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayout.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayout.kt index 21e844a493..d5870b7a12 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayout.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayout.kt @@ -10,8 +10,9 @@ import java.io.File * of those conventions rather than out of a model, so tests build one over a temp dir rather * than faking it. * - * Not all of it is arithmetic: [allSources] and [moduleDirs] walk the tree, so they are disk - * reads and belong off the main thread. The path accessors are arithmetic and cost nothing. + * Not all of it is arithmetic: [allSources], [watchedRoots] and [watchedFiles] walk the tree + * (the latter two through the module scan), so they are disk reads and belong off the main + * thread. The path accessors are arithmetic and cost nothing. * * @property projectRoot the user project's root directory, which the watched gradle config * files and the module scan hang off. diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt index 14bbaf6f8e..e8e30636e5 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt @@ -135,6 +135,12 @@ class QuickBuildScratch( * the space guard. Never throws - a failure comes back as [Preparation.Failed] * with the message provisioning surfaces to the user. * + * A provision that fails after this returned [Preparation.Ready] leaves the tree behind: + * [remove] is keyed off the live layout, which a failed provision never sets, so the tree + * waits for the next session manager start's [sweep]. Tracked as a followup under + * ADFA-5423 rather than fixed here, since the failure paths that leave it live in the + * provisioner. + * * @param projectRoot the project's root directory. * @return [Preparation.Ready] with the tree, or [Preparation.Failed] on a space shortfall or * an unwritable location; an already-existing tree is reused, not cleared. From 041e22ca218fd2397b2a8e1384629ca79d30a944 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:25:10 -0700 Subject: [PATCH 20/26] ADFA-4128: keep the daemon's configured state on its Spawn and kill a child whose start was cancelled configured and scratchFsType move onto Spawn, so a replaced child's late death watcher can no longer clear the flag its successor just set; isRunning reads the current Spawn alone. A start cancelled inside its configure round trip now shuts the child down before rethrowing, where before nothing ever stopped it. A rejected configuration comes back as the daemon's own BuildFailed, with its diagnostics, instead of being flattened into a Failed message. Answers: https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3951660172 https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3951660173 https://github.com/appdevforall/CodeOnTheGo/pull/1720#discussion_r3951660405 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../quickbuild/data/DaemonProcessClient.kt | 59 +++++++++++-------- .../cotg/quickbuild/data/QuickBuildDaemon.kt | 6 +- .../data/DaemonProcessClientEdgeTest.kt | 53 ++++++++++++++--- 3 files changed, 83 insertions(+), 35 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt index babde946e3..f2a1ecde54 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt @@ -101,20 +101,27 @@ class DaemonProcessClient( * requests. Assigned by [startReaders] before the watcher can observe an exit. */ @Volatile var pump: Job? = null + + /** + * Set once this child's `configure` reply was accepted. Lives on the Spawn so that a + * replaced child's late death watcher can never clear it for its successor; dropping the + * Spawn is what ends "running". + */ + @Volatile var configured = false + + /** The filesystem type this child reported for its scratch directory, if any. */ + @Volatile var scratchFsType: String? = null } @Volatile private var spawn: Spawn? = null @Volatile private var deathListener: ((Int) -> Unit)? = null - @Volatile private var configured = false - - @Volatile - override var scratchFsType: String? = null - private set + override val scratchFsType: String? + get() = spawn?.scratchFsType override val isRunning: Boolean - get() = configured && spawn?.process?.isAlive == true + get() = spawn?.let { it.configured && it.process.isAlive } == true /** * Installs the unexpected-exit callback, replacing any previous one. @@ -131,9 +138,10 @@ class DaemonProcessClient( * Shuts down any running daemon, spawns a fresh child JVM, and sends `configure`. * * @param config the session-fixed settings sent in the `configure` request. - * @return [DaemonReply.Ok] once configure succeeded and the protocol version matched, else - * [DaemonReply.Failed] (spawn failure, protocol mismatch, or a rejected configuration) with - * the child shut down first, so a failed start never leaves a daemon behind. + * @return [DaemonReply.Ok] once configure succeeded and the protocol version matched; + * [DaemonReply.BuildFailed] when the daemon rejected the configuration, its diagnostics + * saying why; else [DaemonReply.Failed] (spawn failure or protocol mismatch). Either + * failure has the child shut down first, so a failed start never leaves a daemon behind. */ override suspend fun start(config: DaemonConfig): DaemonReply = startMutex.withLock { startLocked(config) } @@ -144,9 +152,9 @@ class DaemonProcessClient( * @return what [start] returns. */ private suspend fun startLocked(config: DaemonConfig): DaemonReply { - // Also clears scratchFsType and marks the old child's stop on its own Spawn - the - // fresh Spawn below starts with a clean marker of its own. The unlocked body, because - // [startMutex] is already held here and is not reentrant. + // Marks the old child's stop on its own Spawn and drops it, which is also what ends + // isRunning and scratchFsType - the fresh Spawn below starts clean. The unlocked body, + // because [startMutex] is already held here and is not reentrant. shutdownLocked() val proc = @@ -180,6 +188,12 @@ class DaemonProcessClient( startReaders(spawn) try { return configureLocked(spawn, config) + } catch (e: CancellationException) { + // configureLocked only shuts the child down on a reply it saw; a cancel mid round + // trip leaves it alive with nothing else ever stopping it. The kill inside is + // NonCancellable, so it completes under the cancelled job. + shutdownLocked() + throw e } finally { // No-op after a configured start; every other way out, cancellation included, tells // the watcher this child was never the session's. @@ -226,12 +240,12 @@ class DaemonProcessClient( "$EXPECTED_PROTOCOL_VERSION", ) } else { - scratchFsType = + spawn.scratchFsType = configureReply.value .get(ResponseKeys.SCRATCH_FS_TYPE) ?.takeIf { it.isJsonPrimitive } ?.asString - configured = true + spawn.configured = true // Only now is an exit a death: from here the session owns this child. spawn.owned.complete(true) DaemonReply.Ok(Unit) @@ -239,13 +253,13 @@ class DaemonProcessClient( } is DaemonReply.BuildFailed -> { - // The diagnostics say WHY it was rejected and nothing else reads them on this - // path, so the first one travels in the message rather than being dropped. - val why = configureReply.diagnostics.firstOrNull()?.message - DaemonReply.Failed( - "Daemon rejected configuration" + (why?.let { ": $it" } ?: ""), - daemonDied = false, + // Returned as is: the diagnostics say WHY it was rejected, and the caller + // maps a BuildFailed start to its own user-facing message. + log.warn( + "Quick-build daemon rejected configuration: {}", + configureReply.diagnostics.firstOrNull()?.message ?: "no diagnostics", ) + configureReply } is DaemonReply.Failed -> { @@ -382,10 +396,6 @@ class DaemonProcessClient( // Marked before anything can kill it, so every exit from here on is deliberate to the // watcher no matter how late it observes it. spawn.deliberateStop.set(true) - configured = false - // Belongs to the child being stopped: left in place it would stamp the previous - // daemon's filesystem on the next session's timings. - scratchFsType = null val proc = spawn.process val out = spawn.writer // NonCancellable because this is the only thing that kills the child: a teardown @@ -570,7 +580,6 @@ class DaemonProcessClient( log.debug("Replaced quick-build daemon exited with code {}", exitCode) return@launch } - configured = false // Settled by the start, not by which coroutine woke first - see [Spawn.owned]. Safe // to wait on: this child's pending requests were failed above, which is what lets // a start still inside its configure round trip finish and settle it. diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildDaemon.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildDaemon.kt index f24c356b08..1ef8cc0ee3 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildDaemon.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildDaemon.kt @@ -34,9 +34,9 @@ interface QuickBuildDaemon { * * @param config the session-fixed settings; the implementation may retain it for the * lifetime of the process, so callers must not mutate the files it names mid-session. - * @return [DaemonReply.Ok] once the daemon is configured and ready for ops, else - * [DaemonReply.Failed] - a spawn or configure problem is infrastructure, never a - * [DaemonReply.BuildFailed]. + * @return [DaemonReply.Ok] once the daemon is configured and ready for ops; + * [DaemonReply.BuildFailed] when the daemon rejected the configuration, with its + * diagnostics saying why; else [DaemonReply.Failed] for a spawn or transport problem. */ suspend fun start(config: DaemonConfig): DaemonReply diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt index 901589a508..3b08b0f0bb 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt @@ -5,9 +5,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull @@ -144,12 +146,12 @@ class DaemonProcessClientEdgeTest { } @Test - fun `a daemon that rejects configure fails without claiming death`() { + fun `a daemon that rejects configure hands back its diagnostics without claiming death`() { val paths = scriptedPaths( """ read line - printf '%s\n' '{"id":1,"ok":false,"diagnostics":[]}' + printf '%s\n' '{"id":1,"ok":false,"diagnostics":[{"severity":"ERROR","message":"unsupported minApi 19"}]}' read line printf '%s\n' '{"id":2,"ok":true}' """.trimIndent(), @@ -157,10 +159,47 @@ class DaemonProcessClientEdgeTest { val reply = withClient(paths) { it.start(config()) } - assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) - val failed = reply as DaemonReply.Failed - assertThat(failed.message).contains("Daemon rejected configuration") - assertThat(failed.daemonDied).isFalse() + // A BuildFailed, not a Failed: the daemon is healthy and said why, and the session + // manager needs the why to tell the user - and must not respawn for it. + assertThat(reply).isInstanceOf(DaemonReply.BuildFailed::class.java) + val rejected = reply as DaemonReply.BuildFailed + assertThat(rejected.diagnostics.map { it.message }).containsExactly("unsupported minApi 19") + } + + @Test + fun `a start cancelled mid configure kills the child it spawned`() { + val paths = + scriptedPaths( + """ + printf '%s' "${'$'}${'$'}" > '$tmp/daemon.pid' + read line + sleep 30 + """.trimIndent(), + ) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val client = DaemonProcessClient(paths, scope) + val pidFile = File(tmp, "daemon.pid") + + try { + runBlocking { + val startJob = scope.launch { client.start(config()) } + withTimeout(30_000) { while (!pidFile.exists()) delay(20) } + // The child has read nothing back yet, so the start is parked in its configure + // round trip - the window where nothing but the start itself can stop the child. + delay(200) + startJob.cancelAndJoin() + } + + val pid = pidFile.readText().trim() + // The shutdown is asynchronous to the cancel only in the pid's exit bookkeeping, so + // give the kill a moment before declaring the child leaked. + var alive = isProcessAlive(pid) + repeat(50) { if (alive) { Thread.sleep(100); alive = isProcessAlive(pid) } } + assertThat(alive).isFalse() + } finally { + runBlocking { client.shutdown() } + scope.cancel() + } } @Test @@ -1095,7 +1134,7 @@ class DaemonProcessClientEdgeTest { val client = DaemonProcessClient(paths, scope, requestTimeoutMillis = 2_000) try { val reply = runBlocking { client.start(config()) } - assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java) + assertThat(reply).isInstanceOf(DaemonReply.BuildFailed::class.java) assertThat(isProcessAlive(File(tmp, "daemon.pid").readText().trim())).isFalse() } finally { runBlocking { client.shutdown() } From f96facfd2086cb62056b76d61b8ae48f920de72f Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:25:11 -0700 Subject: [PATCH 21/26] ADFA-4128: read only the generation counter's first line and stage each save uniquely A torn or appended-to counter file loaded as null, which is a reused generation; only the first line is the counter now. Each save stages through its own createTempFile sibling instead of a shared .tmp, so two writers on one path cannot rename each other's bytes. Answers: https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3951660160 https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3951660168 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../quickbuild/data/FileGenerationStore.kt | 14 +++++--- .../data/FileGenerationStoreEdgeTest.kt | 3 +- .../data/FileGenerationStoreTest.kt | 34 ++++++++++++++++++- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt index 2481780b07..931e4b3090 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt @@ -22,7 +22,7 @@ import java.io.IOException * must not block. * * @property file the counter file; it need not exist yet, its parent directory is created on - * first [save], and a sibling `.tmp` is the write staging path. + * first [save], and each save stages through its own uniquely named sibling `.tmp`. * @property ioDispatcher where the file I/O runs; injectable so tests can pin the hop. */ class FileGenerationStore( @@ -32,13 +32,15 @@ class FileGenerationStore( /** * Reads the persisted counter. * - * @return the stored generation, or null when the file is missing, unreadable, or does not - * parse as a Long - all of which the caller treats as a fresh session. + * @return the stored generation, or null when the file is missing, unreadable, or its + * first line does not parse as a Long - all of which the caller treats as a fresh + * session. Only the first line is read, so a torn or appended-to file still yields the + * counter it starts with rather than nothing. */ override suspend fun load(): Long? = withContext(ioDispatcher) { try { - if (file.isFile) file.readText().trim().toLongOrNull() else null + if (file.isFile) file.useLines { it.firstOrNull() }?.trim()?.toLongOrNull() else null } catch (e: IOException) { log.warn("Failed to read generation from {}; starting fresh", file, e) null @@ -58,7 +60,9 @@ class FileGenerationStore( override suspend fun save(generation: Long) = withContext(ioDispatcher) { file.parentFile?.mkdirs() - val tmp = File(file.parentFile, file.name + ".tmp") + // A unique staging name per save: two stores on the same path (or two saves racing on + // one) would otherwise stage into a single file and rename each other's bytes. + val tmp = File.createTempFile(file.name + ".", ".tmp", file.parentFile) tmp.writeText(generation.toString()) if (!tmp.renameTo(file)) { // Windows-style rename-over-existing failure path; harmless on device but diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreEdgeTest.kt index 608fc132ed..a237500a40 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreEdgeTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreEdgeTest.kt @@ -76,7 +76,8 @@ class FileGenerationStoreEdgeTest { val target = object : File(path.absolutePath) { override fun delete(): Boolean { - File(parentFile, "$name.tmp").delete() + // The staged temp carries a random middle segment, so sweep the pattern. + parentFile.listFiles { f -> f.name.startsWith("$name.") && f.name.endsWith(".tmp") }?.forEach { it.delete() } return super.delete() } } diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt index 84f97f5f28..9c6914515c 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt @@ -33,6 +33,35 @@ class FileGenerationStoreTest { assertThat(FileGenerationStore(file).load()).isNull() } + @Test + fun `only the first line is the counter`() = + runTest { + // A torn or appended-to file (a crash mid-write, a stray newline from a tool) must not + // turn a persisted counter into a fresh session: that is a generation reused. + val file = File(tempDir, "generation") + file.writeText("42\njunk") + assertThat(FileGenerationStore(file).load()).isEqualTo(42) + } + + @Test + fun `a save leaves no staging file behind`() = + runTest { + val store = store() + store.save(3) + assertThat(stagingFiles()).isEmpty() + } + + @Test + fun `a save never stages into a file another writer already staged`() = + runTest { + // Two stores on one path used to share a single ".tmp"; the second writer's + // rename would then publish whichever bytes the first had left there. + File(tempDir, "generation.tmp").writeText("11") + store().save(12) + assertThat(store().load()).isEqualTo(12) + assertThat(File(tempDir, "generation.tmp").readText()).isEqualTo("11") + } + @Test fun `empty file loads as null`() = runTest { @@ -80,9 +109,12 @@ class FileGenerationStoreTest { val thrown = runCatching { FileGenerationStore(target).save(5) }.exceptionOrNull() assertThat(thrown).isInstanceOf(java.io.IOException::class.java) - assertThat(File(tempDir, "generation.tmp").exists()).isFalse() + assertThat(stagingFiles()).isEmpty() } + /** @return every staged `.tmp` a save could have left beside the counter. */ + private fun stagingFiles(): List = tempDir.listFiles { f -> f.name.startsWith("generation.") && f.name.endsWith(".tmp") }.orEmpty().toList() + @Test fun `forProject uses the canonical androidide state path`() = runTest { From b053b0fbacdabcfb0812bf6c8438b5715fccf98f Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:25:11 -0700 Subject: [PATCH 22/26] ADFA-4128: make the clobber check ask when PackageManager cannot answer A PackageManager read that throws used to propagate out of the tap handler; it now reads as "confirm", since an extra tap is cheaper than a clobbered build. Answers: https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3951660177 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../provision/QuickBuildClobberCheck.kt | 27 +++++++++++++++++-- .../provision/QuickBuildClobberCheckTest.kt | 22 +++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt index 75da838fa6..debe9734ad 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt @@ -1,9 +1,11 @@ package org.appdevforall.cotg.quickbuild.service.provision +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.appdevforall.cotg.quickbuild.domain.reload.RealIdInstall +import org.slf4j.LoggerFactory /** * Decides whether tapping Quick Build or Standard Run should ask the user to confirm a @@ -32,7 +34,7 @@ class QuickBuildClobberCheck( * empty slot needs no confirmation */ suspend fun quickBuildNeedsConfirm(realApplicationId: String): Boolean = - withContext(ioDispatcher) { + failingClosed { RealIdInstall.quickBuildNeedsClobberConfirm( realAppInstalled = packages.uid(realApplicationId) != null, installedFactory = packages.appComponentFactory(realApplicationId), @@ -46,9 +48,30 @@ class QuickBuildClobberCheck( * @return true only when the installed app carries the Quick Build runtime factory */ suspend fun standardRunNeedsConfirm(realApplicationId: String): Boolean = - withContext(ioDispatcher) { + failingClosed { RealIdInstall.standardRunNeedsClobberConfirm( packages.appComponentFactory(realApplicationId), ) } + + /** + * Runs [read] on [ioDispatcher], answering true when it throws: a PackageManager read that + * fails (binder dead, package state mid-change) says nothing about the slot, and an + * unneeded confirmation costs one tap where a skipped one costs the user's installed build. + */ + private suspend fun failingClosed(read: () -> Boolean): Boolean = + withContext(ioDispatcher) { + try { + read() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.warn("Clobber check could not read the installed app; asking to be safe", e) + true + } + } + + private companion object { + private val log = LoggerFactory.getLogger(QuickBuildClobberCheck::class.java) + } } diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheckTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheckTest.kt index c2695da77c..78fe794377 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheckTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheckTest.kt @@ -67,4 +67,26 @@ class QuickBuildClobberCheckTest { assertThat(check(installed = true, factory = null).standardRunNeedsConfirm(realAppId)).isFalse() assertThat(check(installed = false, factory = null).standardRunNeedsConfirm(realAppId)).isFalse() } + + @Test + fun `a PackageManager read that throws asks for confirmation rather than skipping it`() = + runTest { + // Binder can fail mid-call (package state changing, system server pressure); an + // unanswerable check must not read as "empty slot" and let the tap clobber a build. + val check = QuickBuildClobberCheck(ThrowingPackages(), Dispatchers.Unconfined) + assertThat(check.quickBuildNeedsConfirm(realAppId)).isTrue() + assertThat(check.standardRunNeedsConfirm(realAppId)).isTrue() + } + + private class ThrowingPackages : InstalledPackages { + override fun uid(packageName: String): Int? = throw IllegalStateException("binder gone") + + override fun lastUpdateTime(packageName: String): Long? = null + + override fun apkFile(packageName: String): File? = null + + override fun signingCertSha256(packageName: String): String? = null + + override fun appComponentFactory(packageName: String): String? = throw IllegalStateException("binder gone") + } } From 71d979e3426783604a96faceca9a6caf048b417a Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:25:11 -0700 Subject: [PATCH 23/26] ADFA-4128: round 5 doc and comment fixes on quickbuild/core (PR 7) Installer KDocs: the timeout budget covers the verdict, not the uid read that follows it; a nameless broadcast errs toward a reported failure. Drop the ticket-era "Defect T12" label from a test comment. concurrency.md: the request timeout applies per phase, so up to 600 s. Answers: https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3951660186 https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3951660187 https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3951660181 https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3951660182 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../cotg/quickbuild/service/provision/ProxyAppInstaller.kt | 7 +++++-- .../quickbuild/service/provision/ProxyAppInstallerTest.kt | 2 +- quickbuild/docs/concurrency.md | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt index 3c73e43cd3..9951eaba05 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt @@ -155,7 +155,7 @@ sealed interface InstallOutcome { * free of reinstalls across rebaselines and CoGo restarts. Failures arrive as PackageInstaller * broadcasts with real messages, and a lastUpdateTime change backstops the MIUI intent * fallback, which never broadcasts through our receiver. A broadcast with no package name is - * accepted as ours, erring toward a retryable failure rather than a false success. + * accepted as ours, erring toward a reported failure rather than a false success. * * Blocking work - the APK hashing and every [InstalledPackages] read (binder calls into * PackageManager) - runs under [ioDispatcher], so [ensureInstalled] is safe to call from the @@ -168,7 +168,10 @@ class ProxyAppInstaller( private val launchInstall: suspend (File) -> Boolean, /** InstallationResultReceiver broadcasts, adapted app-side. */ private val broadcasts: Flow, - /** Whole-install budget, including the time the user spends tapping through dialogs. */ + /** + * Budget for the install verdict, dialogs included. The bounded uid read that follows a + * verdict (up to UID_RETRIES * DEFAULT_POLL_MILLIS) is outside it. + */ private val timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, /** * How long one committed install may sit without any verdict before the prompt is diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt index 9051953980..90501bba9b 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstallerTest.kt @@ -363,7 +363,7 @@ class ProxyAppInstallerTest { @Test fun `a prompt nobody was ever shown is re-issued once inside the same budget`() = runTest { - // Defect T12: after a CoGo process death the first install's confirm dialog can + // After a CoGo process death the first install's confirm dialog can // be lost - the OS asks, the lifecycle-bound dialog owner is not there to launch // it, and nothing distinguishes that from a user reading the dialog. The install // must re-prompt rather than spend the whole budget in silence. diff --git a/quickbuild/docs/concurrency.md b/quickbuild/docs/concurrency.md index e33472e4f2..e3bd2a3b38 100644 --- a/quickbuild/docs/concurrency.md +++ b/quickbuild/docs/concurrency.md @@ -53,7 +53,7 @@ Every dotted edge is a result **hopping back onto the session thread**. Nothing - **The reducer is total.** An unhandled `(state, event)` pair keeps the state and produces no effects ([`SessionReducer`](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/SessionReducer.kt)), so a late, duplicate or out-of-order event is a no-op rather than a corrupt session. Every guard below can therefore be "drop it" instead of "unwind it". - **Nothing on that thread may block.** Every outward call is `suspend`; the daemon client hops its process I/O to `Dispatchers.IO`, the watcher runs its stat sweep there, and the file-touching helpers (`ProxyAppInstaller`, `QuickBuildClobberCheck`, `QuickBuildScratch`, `FileGenerationStore` and the `GenerationTracker` over it) take an injected I/O dispatcher for the same reason. A blocking call added here stalls the whole session. -**What is farmed out, and how results come back.** The session thread never compiles anything. Each build is one suspending pass through [`LiveReloadExecutorImpl`](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImpl.kt) - compile, dex, relink, deploy, strictly in order, each step one request to the daemon. The daemon holds **one request in flight** (`requestMutex`) and its own loop is single-threaded on purpose, so the pipeline is serial end to end; a request that exceeds `requestTimeoutMillis` (300 s) comes back as a failed reply rather than an exception. Results re-enter the model three ways, all of them hopping back onto the session thread: the executor's return value becomes an `OrchestratorEvent`, which the orchestrator delivers *outside* its own lock and the manager `launch`es into a dispatch; the proxy app's crash and reconnect reports arrive as flows collected on the session scope; the daemon's death arrives as a listener callback that dispatches `DaemonDied`. +**What is farmed out, and how results come back.** The session thread never compiles anything. Each build is one suspending pass through [`LiveReloadExecutorImpl`](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImpl.kt) - compile, dex, relink, deploy, strictly in order, each step one request to the daemon. The daemon holds **one request in flight** (`requestMutex`) and its own loop is single-threaded on purpose, so the pipeline is serial end to end; a request that exceeds `requestTimeoutMillis` (300 s, applied per phase - the write, then the response - so up to 600 s) comes back as a failed reply rather than an exception. Results re-enter the model three ways, all of them hopping back onto the session thread: the executor's return value becomes an `OrchestratorEvent`, which the orchestrator delivers *outside* its own lock and the manager `launch`es into a dispatch; the proxy app's crash and reconnect reports arrive as flows collected on the session scope; the daemon's death arrives as a listener callback that dispatches `DaemonDied`. **Quick Build vs Standard Run: two shared resources.** They contend for the device's one Gradle slot and the project's one package slot, and each has an explicit gate rather than a lock. From 158d3541ec272cb32fad805be8ab178161b59d2b Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:45:13 -0700 Subject: [PATCH 24/26] style: spotless reformat, no functional change ktlint line wrap for the staging-file helper added in the round 5 fixes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../cotg/quickbuild/data/FileGenerationStoreTest.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt index 9c6914515c..1162e0465e 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStoreTest.kt @@ -113,7 +113,12 @@ class FileGenerationStoreTest { } /** @return every staged `.tmp` a save could have left beside the counter. */ - private fun stagingFiles(): List = tempDir.listFiles { f -> f.name.startsWith("generation.") && f.name.endsWith(".tmp") }.orEmpty().toList() + private fun stagingFiles(): List = + tempDir + .listFiles { f -> + f.name.startsWith("generation.") && f.name.endsWith(".tmp") + }.orEmpty() + .toList() @Test fun `forProject uses the canonical androidide state path`() = From 66133170f9258681feddefcc6b33ade11ee67bce Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:47:52 -0700 Subject: [PATCH 25/26] ADFA-4128: keep the cancel-mid-configure test inside ktlint's if-wrapping rule Same wait, written as a while loop. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../quickbuild/data/DaemonProcessClientEdgeTest.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt index 3b08b0f0bb..3241e824ea 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt @@ -191,11 +191,11 @@ class DaemonProcessClientEdgeTest { } val pid = pidFile.readText().trim() - // The shutdown is asynchronous to the cancel only in the pid's exit bookkeeping, so - // give the kill a moment before declaring the child leaked. - var alive = isProcessAlive(pid) - repeat(50) { if (alive) { Thread.sleep(100); alive = isProcessAlive(pid) } } - assertThat(alive).isFalse() + // The kill is complete when cancelAndJoin returns, but the OS reaps the child a + // beat later, so give it a moment before declaring the child leaked. + var attempts = 0 + while (isProcessAlive(pid) && attempts++ < 50) Thread.sleep(100) + assertThat(isProcessAlive(pid)).isFalse() } finally { runBlocking { client.shutdown() } scope.cancel() From cfc7abfe4f96fa60f0a623d811bead2b8f70966f Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:50:30 -0700 Subject: [PATCH 26/26] style: spotless reformat, no functional change ktlint import order for the cancelAndJoin import added in the round 5 fixes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt index 3241e824ea..7cd9c77ca0 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt @@ -5,8 +5,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async -import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch