From 0dae6f241c9060fab3b84d69f129c39b41b1caee Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 15:03:33 -0700 Subject: [PATCH 1/2] =?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 97f4813f2b51b872b56411add0554c1c2984ef5f Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 22:47:48 -0700 Subject: [PATCH 2/2] =?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 =