From 1d967d8de8f5192e3bf1cc4a4c166cd5d1cd7edd Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 15:03:33 -0700 Subject: [PATCH 1/6] =?UTF-8?q?ADFA-4128:=20qb=2007/12=20core-provisioning?= =?UTF-8?q?=20=E2=80=94=20Core=20slice=203:=20proxy-app=20install=20state?= =?UTF-8?q?=20and=20the=20compile-daemon=20client=20the=20pipeline=20needs?= =?UTF-8?q?=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 7e95f448af52099334967faa43788ea199d0a09f Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 22:47:48 -0700 Subject: [PATCH 2/6] =?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 4e5c1f36520b18de600e2579c2e2d532a8ce5ebe Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 26 Aug 2026 23:43:45 -0700 Subject: [PATCH 3/6] 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 a38875ca8913486d12b11141d2170c48749e82d7 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 31 Aug 2026 18:48:07 -0700 Subject: [PATCH 4/6] 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 a2f13f2da1fa882cb1f78d28ff4138631b9379f4 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 2 Sep 2026 17:27:12 -0700 Subject: [PATCH 5/6] 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 973f6accbb6b29ecadc0fdac263f3c2c386887d3 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 2 Sep 2026 18:37:03 -0700 Subject: [PATCH 6/6] 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].