From df5c74ae7c9aa5236ec22148dc8c40a1afc5c30d Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 15:03:32 -0700 Subject: [PATCH 1/3] =?UTF-8?q?ADFA-4128:=20qb=2006/12=20core-deploy=20?= =?UTF-8?q?=E2=80=94=20Core=20slice=202:=20reload-vs-restart=20policy,=20t?= =?UTF-8?q?he=20binder=20deploy=20channel,=20stage-cost=20telemetry?= 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 --- .../cotg/quickbuild/data/AssetPackager.kt | 87 + .../quickbuild/domain/reload/ClassHeader.kt | 109 + .../quickbuild/domain/reload/ComponentInfo.kt | 94 + .../quickbuild/domain/reload/DeployPolicy.kt | 99 + .../domain/reload/GenerationTracker.kt | 76 + .../domain/reload/LiveReloadExecutor.kt | 170 ++ .../domain/reload/LiveReloadOrchestrator.kt | 929 +++++++ .../cotg/quickbuild/domain/reload/README.md | 13 + .../quickbuild/domain/reload/RealIdInstall.kt | 102 + .../domain/session/QuickBuildMessage.kt | 121 + .../domain/session/QuickBuildNotice.kt | 66 + .../domain/telemetry/E2eTimeline.kt | 242 ++ .../domain/telemetry/QuickBuildMetricsSink.kt | 116 + .../quickbuild/domain/telemetry/README.md | 8 + .../service/deploy/BuildStatusJson.kt | 95 + .../service/deploy/DeployChannel.kt | 247 ++ .../service/deploy/PayloadDeployer.kt | 413 +++ .../service/deploy/ProxyAppConnections.kt | 199 ++ .../service/deploy/ProxyAppPriorityHold.kt | 143 ++ .../service/deploy/QuickBuildHostService.kt | 145 ++ .../cotg/quickbuild/service/deploy/README.md | 11 + .../service/deploy/RetainedPayloadStore.kt | 169 ++ .../service/provision/ProxyAppLauncher.kt | 27 + .../service/telemetry/E2eTimelineRecorder.kt | 189 ++ .../service/telemetry/MetricsReporting.kt | 25 + .../quickbuild/service/telemetry/README.md | 8 + .../cotg/quickbuild/data/AssetPackagerTest.kt | 112 + .../domain/reload/ClassHeaderEdgeTest.kt | 143 ++ .../domain/reload/ClassHeaderTest.kt | 77 + .../domain/reload/DeployPolicyTest.kt | 223 ++ .../domain/reload/GenerationTrackerTest.kt | 96 + .../reload/LiveReloadOrchestratorTest.kt | 2206 +++++++++++++++++ .../domain/reload/RealIdInstallTest.kt | 119 + .../domain/telemetry/E2eTimelineGroupsTest.kt | 95 + .../domain/telemetry/E2eTimelineTest.kt | 204 ++ .../domain/watch/SaveCoalescingE2eTest.kt | 331 +++ .../cotg/quickbuild/service/Fakes.kt | 69 + .../service/deploy/BuildStatusJsonTest.kt | 129 + .../service/deploy/DeployChannelDeployTest.kt | 189 ++ .../service/deploy/DeployChannelWaitsTest.kt | 92 + .../deploy/PayloadDeployerRetentionTest.kt | 164 ++ .../service/deploy/PayloadDeployerTest.kt | 297 +++ .../ProxyAppConnectionsFreezerHoldTest.kt | 153 ++ .../deploy/ProxyAppPriorityHoldTest.kt | 111 + .../deploy/QuickBuildHostBinderTest.kt | 187 ++ .../deploy/RetainedPayloadStoreTest.kt | 112 + .../telemetry/E2eTimelineRecorderTest.kt | 49 + 47 files changed, 9061 insertions(+) create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/AssetPackager.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/ClassHeader.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/ComponentInfo.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/DeployPolicy.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTracker.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadExecutor.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestrator.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/README.md create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/RealIdInstall.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildMessage.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildNotice.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimeline.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/QuickBuildMetricsSink.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/README.md create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/BuildStatusJson.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannel.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppConnections.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppPriorityHold.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/QuickBuildHostService.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/README.md create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/RetainedPayloadStore.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppLauncher.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/E2eTimelineRecorder.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/MetricsReporting.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/README.md create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/AssetPackagerTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/ClassHeaderEdgeTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/ClassHeaderTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/DeployPolicyTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTrackerTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestratorTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/RealIdInstallTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimelineGroupsTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimelineTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/watch/SaveCoalescingE2eTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/BuildStatusJsonTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannelDeployTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannelWaitsTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerRetentionTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppConnectionsFreezerHoldTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppPriorityHoldTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/QuickBuildHostBinderTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/RetainedPayloadStoreTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/telemetry/E2eTimelineRecorderTest.kt diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/AssetPackager.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/AssetPackager.kt new file mode 100644 index 0000000000..e10aef4dff --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/AssetPackager.kt @@ -0,0 +1,87 @@ +package org.appdevforall.cotg.quickbuild.data + +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * Packages changed asset files into the deploy payload zip. + * + * Entry names are asset-relative paths with forward slashes (`data/levels.json`), which is how + * the runtime's asset overlay keys them, so an entry lands 1:1 over the asset it replaces. + */ +class AssetPackager { + /** + * Maps [file] to its path relative to whichever of [assetRoots] contains it, or null if none + * does. + * + * Both sides are normalized first: without that, `/sub/../../evil` passes the raw-text + * containment test and names a zip entry that escapes the asset directory on unpack. + * + * @param file candidate path; need not exist, since containment is decided on the path text + * alone. + * @param assetRoots asset roots to test, in order; the first one containing [file] wins. + * @return the '/'-separated path relative to the matching root, or null when [file] lies under + * none of them (a root itself never matches), never containing a `..` segment. + */ + fun relativeAssetPath( + file: File, + assetRoots: List, + ): String? { + val abs = file.absoluteFile.normalize() + for (root in assetRoots) { + val rootAbs = root.absoluteFile.normalize() + val rootPath = rootAbs.path + File.separator + if (abs.path.startsWith(rootPath)) { + return abs.path.removePrefix(rootPath).replace(File.separatorChar, '/') + } + } + return null + } + + /** + * Zips [changedFiles] (only those under an asset root) into [outFile]. + * + * @param changedFiles this build's changed set, assets and non-assets mixed; entries + * outside every asset root are ignored. + * @param assetRoots the module's asset roots, which name the zip entries. + * @param outFile zip to write; overwritten, and its parent directory is created. + * @return the written zip and the relative entry paths, or null when the changed set + * contains no asset files, in which case callers omit the assets payload entirely. + */ + fun packageAssets( + changedFiles: Collection, + assetRoots: List, + outFile: File, + ): PackagedAssets? { + val entries = + changedFiles.mapNotNull { file -> + relativeAssetPath(file, assetRoots)?.let { rel -> rel to file } + } + if (entries.isEmpty()) return null + + outFile.parentFile?.mkdirs() + ZipOutputStream(outFile.outputStream().buffered()).use { zip -> + for ((rel, file) in entries.sortedBy { it.first }) { + if (!file.isFile) continue // deleted asset: absence is the signal for v1 + zip.putNextEntry(ZipEntry(rel)) + file.inputStream().use { it.copyTo(zip) } + zip.closeEntry() + } + } + return PackagedAssets(outFile, entries.map { it.first }.sorted()) + } + + /** + * A written assets zip and the entry paths inside it. + * + * @property zip the file just written; always exists, even when every changed asset was a + * deletion and the archive is therefore empty. + * @property relativePaths sorted, '/'-separated asset-relative entry names, including deleted + * assets that have no entry in [zip], so this is a superset of the archive's contents. + */ + data class PackagedAssets( + val zip: File, + val relativePaths: List, + ) +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/ClassHeader.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/ClassHeader.kt new file mode 100644 index 0000000000..c32d6d9fcc --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/ClassHeader.kt @@ -0,0 +1,109 @@ +package org.appdevforall.cotg.quickbuild.domain.reload + +import java.io.DataInputStream + +/** + * The hierarchy facts of one compiled class file - name, superclass, directly implemented + * interfaces - which is what keeps [DeployPolicy]'s supertype index current across builds. + * + * Parsed by a constant-pool walk rather than a bytecode library; these fields sit right after + * the constant pool, so nothing past the interface list is read. Names are in dot form with + * `$` for nested classes (`com.example.Outer$Inner`). + * + * @property className the class's own FQN in dot form. + * @property superClassName the direct superclass FQN; null only for `java.lang.Object` itself + * and for interfaces, which declare no superclass. + * @property interfaceNames the directly implemented interface FQNs, in declaration order; + * inherited ones are not listed, since the header does not carry them. + */ +data class ClassHeader( + val className: String, + val superClassName: String?, + val interfaceNames: List, +) { + companion object { + // Reading the 0xCAFEBABE class-file magic back as a signed Int is negative, because + // 0xCAFEBABE > Int.MAX_VALUE. + private const val CLASS_MAGIC = -0x35014542 // 0xCAFEBABE + + /** + * Parses one class file's header. + * + * @param bytes the whole class file; only the prefix through the interface list is read, + * so a truncated tail is harmless. + * @return the header, or null when the bytes are not a well-formed class file, which + * callers skip rather than failing the build over. + */ + fun parse(bytes: ByteArray): ClassHeader? = + try { + DataInputStream(bytes.inputStream()).use(::parseStream) + } catch (e: Exception) { + // Swallowed because an over-restart is safe, whereas throwing would fail the + // whole build over one unreadable class. + null + } + + private fun parseStream(input: DataInputStream): ClassHeader? { + if (input.readInt() != CLASS_MAGIC) return null + input.readUnsignedShort() // minor + input.readUnsignedShort() // major + + val constantCount = input.readUnsignedShort() + val utf8 = HashMap() + val classNameIndex = HashMap() + // Walk the constant pool to collect just what resolves a class name: UTF-8 strings + // (tag 1) and Class entries (tag 7, which point at a UTF-8 slot). Every other entry + // type is skipped by its fixed byte width - we only need names, not the full pool. + var index = 1 + while (index < constantCount) { + val tag = input.readUnsignedByte() + when (tag) { + 1 -> { + utf8[index] = input.readUTF() + } + + 7 -> { + classNameIndex[index] = input.readUnsignedShort() + } + + 8, 16, 19, 20 -> { + input.skipBytes(2) + } + + 15 -> { + input.skipBytes(3) + } + + 3, 4, 9, 10, 11, 12, 17, 18 -> { + input.skipBytes(4) + } + + 5, 6 -> { + input.skipBytes(8) + index++ // longs/doubles occupy two constant-pool slots + } + + else -> { + return null + } + } + index++ + } + + input.readUnsignedShort() // access flags + val thisClass = className(input.readUnsignedShort(), classNameIndex, utf8) ?: return null + val superClass = className(input.readUnsignedShort(), classNameIndex, utf8) + val interfaces = + (0 until input.readUnsignedShort()).mapNotNull { + className(input.readUnsignedShort(), classNameIndex, utf8) + } + return ClassHeader(thisClass, superClass, interfaces) + } + + private fun className( + classIndex: Int, + classNameIndex: Map, + utf8: Map, + ): String? = classNameIndex[classIndex]?.let(utf8::get)?.replace('/', '.') + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/ComponentInfo.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/ComponentInfo.kt new file mode 100644 index 0000000000..03558e4183 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/ComponentInfo.kt @@ -0,0 +1,94 @@ +package org.appdevforall.cotg.quickbuild.domain.reload + +/** + * Kind of a manifest component the proxy app build recorded (setup.json `components`). + * + * The restart closure referred to throughout this file is [DeployPolicy]'s: a + * restart-sensitive component class plus its user-side supertypes and their nested classes, + * any recompile of which forces a proxy-app process restart. + */ +enum class ComponentKind { + /** An ``; outside the restart closure, since recreate already refreshes it. */ + ACTIVITY, + + /** A ``; a live instance cannot be swapped, so it forces a process restart. */ + SERVICE, + + /** A ``; outside the restart closure, being instantiated fresh per delivery. */ + RECEIVER, + + /** A ``; like a service, a live instance forces a process restart. */ + PROVIDER, + + /** The custom `Application` class; forces a process restart, and has no proxy class. */ + APPLICATION, +} + +/** + * The kinds whose live instance a loader swap cannot update, so a recompile inside their + * restart closure forces a process restart ([DeployPolicy]). + * + * One home for the set, because two rules key off it: the restart decision, and the + * [org.appdevforall.cotg.quickbuild.domain.session.QuickBuildNotice.STALE_COMPONENT_HELPERS] warning that fires when one of these merely + * EXISTS and the deploy hot-swapped instead. Both read it through [isRestartSensitive], which + * also applies the [COGO_INJECTED_COMPONENTS] exemption. + */ +val RESTART_SENSITIVE_KINDS: Set = + setOf(ComponentKind.SERVICE, ComponentKind.PROVIDER, ComponentKind.APPLICATION) + +/** + * The restart-sensitive components CoGo injects into every debuggable app it builds - the + * logsender AAR's service and the provider that installs it - which the restart rule exempts. + * + * Safe because these two classes ship in the BASE APK dex and are absent from every + * per-generation payload dex, which is exactly the daemon's compile output plus the generated + * proxy classes. Payload loaders are parent-first with the APK loader as parent, so every + * generation's proxy resolves the same `Class` object for these supertypes - their identity + * never changes across a hot swap, and the `ClassCastException` the restart rule exists to + * prevent cannot arise from them. The proxies themselves hold no state: `ProxySourceGenerator` + * emits an empty subclass for services and providers. + * + * Keyed on the EXACT class name, never a package prefix or a "library-provided" test: the + * safety comes from these specific classes being absent from the payload, and any library class + * that DID land in the payload would still be redefined per generation. Same shape, and same + * reason, as `ComponentProxiabilityResolver.UNPROXIABLE_BY_NAME` in the Gradle plugin. + */ +val COGO_INJECTED_COMPONENTS: Set = + setOf( + "com.itsaky.androidide.logsender.LogSenderService", + "com.itsaky.androidide.logsender.utils.LogSenderInstaller", + ) + +/** + * Whether a code deploy must restart the process because of this component: its kind is one a + * loader swap cannot update ([RESTART_SENSITIVE_KINDS]) and it is not one CoGo injected + * ([COGO_INJECTED_COMPONENTS]). + * + * The one home for the rule, because both consumers must agree: exempting it in [DeployPolicy] + * alone would turn every hot swap on an ordinary app into a spurious stale-helpers warning + * about CoGo's own logsender. + */ +fun ComponentInfo.isRestartSensitive(): Boolean = kind in RESTART_SENSITIVE_KINDS && className !in COGO_INJECTED_COMPONENTS + +/** + * One manifest component recorded by the proxy app build (setup.json `components`, schema v2). + * + * Carries only what the deploy policy and restart UX need; intent filters, permissions and + * the like transfer verbatim in the manifest and are not duplicated here. + * + * @property kind which manifest tag declared it, which is what decides restart vs recreate. + * @property className the USER class FQN declared in the source manifest. + * @property proxyClass the generated proxy FQN carried in the transformed manifest; + * null for the Application entry (nothing addresses it by manifest name). + * @property launcher true for the launcher activity - its [proxyClass] is the explicit + * relaunch target after a restart-deploy. + * @property supertypes the user-side (project-compiled) superclass chain recorded from + * class headers at proxy app build time; seeds the restart closure's supertype index. + */ +data class ComponentInfo( + val kind: ComponentKind, + val className: String, + val proxyClass: String? = null, + val launcher: Boolean = false, + val supertypes: List = emptyList(), +) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/DeployPolicy.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/DeployPolicy.kt new file mode 100644 index 0000000000..c3e8f50cff --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/DeployPolicy.kt @@ -0,0 +1,99 @@ +package org.appdevforall.cotg.quickbuild.domain.reload + +/** + * What a successful code-bearing quick build should do to the proxy app. + * + * A loader swap plus activity recreate cannot update a live Service, ContentProvider or + * custom Application instance, so an app that declares one must restart the proxy-app process + * on every code-bearing deploy. Restarting is safe: the relaunched proxy app boots the newest + * persisted generation and binder catch-up reconciles the rest. + */ +sealed interface DeployDecision { + /** Hot swap the loader and recreate the activity - the usual path. */ + data object Recreate : DeployDecision + + /** + * The app holds [componentClass] (a [kind]) across reloads, so this deploy must restart. + * + * @property kind what the held component is, so the status surface can name it to the user. + * @property componentClass the USER class FQN of a restart-sensitive component the app + * declares; the first one wins, so it names a cause rather than the complete set of them. + */ + data class Restart( + val kind: ComponentKind, + val componentClass: String, + ) : DeployDecision + + /** + * The installed baseline cannot take this deploy safely (it predates the component + * metadata, so its runtime would ignore a restart request and hot-swap = stale). + * The session must fall back to a full proxy app rebuild, which regenerates the baseline. + * + * @property detail human-readable cause, carried into the fallback's user-facing message. + */ + data class RebuildProxyApp( + val detail: String, + ) : DeployDecision +} + +/** + * Decides restart vs recreate after a successful compile (see component-proxying-design.md, + * "Restart vs recreate"). + * + * The rule is whether the app declares any component whose live instance a loader swap cannot + * update - a [ComponentKind.SERVICE], [ComponentKind.PROVIDER] or custom + * [ComponentKind.APPLICATION] ([RESTART_SENSITIVE_KINDS]). If it declares one, every + * code-bearing deploy restarts the process; if it declares none, every deploy hot swaps. + * Receivers and activities never count: manifest receivers are instantiated fresh per delivery + * through the factory, and activities are covered by recreate. Nor do the components CoGo + * itself injects ([COGO_INJECTED_COMPONENTS]) - they ship in the base APK dex, so no payload + * ever redefines them; without that exemption every app would restart on every save, since + * logsender is injected into every debuggable build. + * + * The rule deliberately does not look at what the compile touched. Every generation ships the + * WHOLE user class set - `DexTool.dex` dexes the compiler's output tree, never a delta - so a + * hot swap re-defines every user class through a fresh loader whatever the edit was. A held + * Service, ContentProvider or custom `Application` keeps the previous copy, and the first cast + * across the two throws `ClassCastException: Foo cannot be cast to Foo`. Keying on the + * recompiled set is what let an activity-only edit crash the app, reproduced on device + * (spike2-repro-restart-jvmti-2026-08-20.md). + */ +class DeployPolicy( + /** + * The baseline's manifest components as the proxy app build recorded them; only their + * [ComponentInfo.kind] and [ComponentInfo.className] are read. + */ + components: List, + /** + * False when the baseline's setup.json predates schema v2: the component list is + * unknowable and that runtime ignores restart requests, so every code-bearing deploy + * returns [DeployDecision.RebuildProxyApp], which regenerates a v2 baseline. + */ + private val componentInfoAvailable: Boolean = true, +) { + /** The declared components a loader swap cannot update; the first one names the cause. */ + private val heldComponent = components.firstOrNull { it.isRestartSensitive() } + + /** + * Decides what one successful compile's output requires of the running proxy app. + * + * @param changedClassFiles the .class paths this compile emitted, or null when the + * recompiled set is unknown. Read only to spot a compile that emitted nothing at all on a + * baseline with no usable component list; the restart rule itself ignores it, because the + * payload is the whole class set either way (see the class doc). + * @return restart when the app declares a restart-sensitive component, a proxy app rebuild + * when the baseline is too old to honour one, else recreate. + */ + fun decide(changedClassFiles: Collection?): DeployDecision { + if (componentInfoAvailable) { + val held = heldComponent ?: return DeployDecision.Recreate + return DeployDecision.Restart(held.kind, held.className) + } + // A compile that emitted nothing deploys nothing that can stale a component, so it is + // not worth a full proxy app rebuild on an old baseline. + if (changedClassFiles != null && changedClassFiles.isEmpty()) return DeployDecision.Recreate + return DeployDecision.RebuildProxyApp( + "the installed baseline predates component metadata (setup.json schema v2)", + ) + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTracker.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTracker.kt new file mode 100644 index 0000000000..abe26b2359 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTracker.kt @@ -0,0 +1,76 @@ +package org.appdevforall.cotg.quickbuild.domain.reload + +/** + * Persistence for the session's generation counter. Implementations live in the data + * layer (a file under the project's `.androidide` state dir); tests use an in-memory fake. + */ +interface GenerationStore { + /** + * Reads the last persisted generation. + * + * @return the stored number, or null when no session has ever run for this project - an + * unreadable store must also answer null, since a throw would fail session startup. + */ + fun load(): Long? + + /** + * Persists [generation] so it survives a CoGo restart. + * + * @param generation the number just allocated, written before it is handed out so that a + * crash burns it rather than letting a later session reuse it. + */ + fun save(generation: Long) +} + +/** + * Hands out monotonically increasing generation numbers for deploy payloads. + * + * The proxy app accepts a payload only if its generation is newer than the one it runs, so this + * counter is what makes "an old payload can never replace a newer one" true even across a CoGo + * crash: [next] persists before returning, burning a number rather than reusing it. + * + * Not thread-safe - call from the orchestrator's single-threaded context. + * + * @param store where the counter survives a restart; read once at construction, so a store + * changed underneath a live tracker is not noticed. + */ +class GenerationTracker( + private val store: GenerationStore, +) { + /** The most recently allocated generation; 0 before any session has run. */ + var current: Long = store.load() ?: 0L + private set + + /** + * Allocates the next generation, persisting it before it is handed out. + * + * @return the new [current], always strictly greater than the previous one; a failed save + * propagates, so no number is handed out that the store did not accept. + */ + fun next(): Long { + val next = current + 1 + store.save(next) + current = next + return next + } + + /** + * Adopts a generation another allocator over the same store handed out, so [next] stays + * strictly above it. + * + * The proxy app build stamps its baseline generation through a host-side tracker over the + * same per-project store, while this tracker read the store once at construction. Without + * adopting the stamp after a rebaseline, [next] would hand out numbers at or below the + * freshly installed baseline and the runtime would reject every later deploy as stale. + * Persists like [next], so a crash cannot resurrect a number below an installed baseline. + * + * @param generation the stamped baseline generation; values at or below [current] are + * no-ops, so an unstamped (0) baseline never moves the counter. + */ + fun adoptAtLeast(generation: Long) { + if (generation > current) { + store.save(generation) + current = generation + } + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadExecutor.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadExecutor.kt new file mode 100644 index 0000000000..f8fc778cd0 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadExecutor.kt @@ -0,0 +1,170 @@ +package org.appdevforall.cotg.quickbuild.domain.reload + +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason + +/** + * Runs one quick build end to end: compile (if the route needs it), dex, relink, deploy. + * + * Called with at most one request in flight (the [LiveReloadOrchestrator] guarantees it), and + * never with a [BuildRoute.FullGradleBuild] route. Must NOT throw for build problems - report + * them as a [BuildOutcome]; an escaped exception becomes [BuildOutcome.InfrastructureFailure]. + */ +interface LiveReloadExecutor { + /** + * Runs one build to completion and reports how it ended. + * + * @param request what to build, already routed; the executor does not re-classify it. + * @return how the build ended - only [BuildOutcome.Success] means the proxy app moved to a new + * generation, every other outcome leaves it on the old one. + */ + suspend fun execute(request: BuildRequest): BuildOutcome + + /** + * Promotes the build already running to a user-initiated one, so its deploy may take the + * foreground. + * + * A tap landing while a save's build is in flight is answered by that build rather than + * queueing a second, so the intent arrives after [execute] was called with + * [BuildRequest.userInitiated] false; without this a tap against a closed app would do nothing. + */ + fun markCurrentBuildUserInitiated() = Unit +} + +/** + * One build the executor is asked to run. + * + * @property buildId orchestrator-unique id; tags diagnostics so a superseded build's output is + * discarded rather than rendered. + * @property changes the coalesced changed-set this build must absorb, with [ChangedFiles.Unknown] + * meaning recompile everything. + * @property route the classifier's verdict, which fixes which steps run; never a + * [BuildRoute.FullGradleBuild]. + * @property forced true for an explicit Quick Build tap - the executor must deploy even when + * [changes] is empty, by rebuilding the current sources at a FRESH generation, since the + * runtime only accepts strictly-newer generations. + * @property triggeredAtMillis monotonic stamp of when the earliest change in this build started + * WAITING for it - t0 of the [org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline], on + * the clock the executor stamps t1-t3 with, restarted at the next save for a batch a failed + * build handed back (see `LiveReloadOrchestrator.pendingSince`) and 0 when there is no clock. + * @property userInitiated true only when a Quick Build tap asked for this build, which is what + * licenses the deploy to bring the proxy app to the foreground - a save must never take the + * screen from someone who is still typing. + */ +data class BuildRequest( + val buildId: Long, + val changes: ChangedFiles, + val route: BuildRoute, + val forced: Boolean = false, + val triggeredAtMillis: Long = 0L, + val userInitiated: Boolean = false, +) + +/** How one build ended. */ +sealed interface BuildOutcome { + /** + * Compiled, deployed and reloaded: the proxy app now runs [generation]. + * + * @property generation the generation the proxy app confirmed live, not merely the one sent. + * @property durationMillis the whole save-to-live loop measured from [triggeredAtMillis] - the + * span the user actually waited, not build time alone, which reads as a second contradictory + * total beside the timing line; falls back to the build's own start with no trigger stamp. + * @property restarted true when the deploy took the process-restart path (a service, + * provider or Application class changed) instead of a hot swap. + */ + data class Success( + val generation: Long, + val durationMillis: Long, + val restarted: Boolean = false, + ) : BuildOutcome + + /** + * The build succeeded but must not be deployed: the installed baseline would hot-swap a + * restart-requiring payload and leave a live service stale. + * + * The session manager routes [reason] into the proxy-app-rebuild fallback, which + * regenerates the baseline; the changed set stays pending and is absorbed there. + * + * @property reason what the session manager reports and acts on. + * @property detail human-readable cause behind [reason], for the status surface. + */ + data class RequiresProxyAppRebuild( + val reason: InvalidationReason, + val detail: String, + ) : BuildOutcome + + /** + * The changed-set does not compile. The proxy app keeps running the old generation. + * + * @property diagnostics every compiler message, warnings included; equality across two + * builds is what the orchestrator's duplicate-follow-up guard turns on. + * @property kotlinDeclaredChanged Kotlin sources the daemon declared changed to its engine, + * or null when it reported none. 0 vs >= 1 separates two causes of a stale mixed-language + * output: 0 means the edit never entered the dirty set, so the fix is upstream in + * changed-set assembly; >= 1 means it did and the staleness is downstream. Null is + * ABSENT, never a measured zero. + * @property allSources the source set this compile was handed - [kotlinDeclaredChanged]'s + * denominator, without which "0" cannot be read. + * @property javaSources `.java` count, all of which javac recompiles every build. + * + * These are DIAGNOSTIC counts for the bench feed, deliberately plain numbers rather than the + * daemon's `CompileStats`: no file in this domain package imports the wire protocol, and a + * counter is a poor reason to be the first. They must NOT be threaded into `SessionFailure` + * - that boundary is what keeps them out of the user-facing Build Output pane, which + * consumes `SessionFailure` and should never show an engine statistic. + */ + data class CompileError( + val diagnostics: List, + val kotlinDeclaredChanged: Int? = null, + val allSources: Int? = null, + val javaSources: Int? = null, + ) : BuildOutcome + + /** + * Compile succeeded but the payload never reached the proxy app (deploy/reload failed). + * + * @property message what failed, for the status surface; the built outputs stay on disk, so + * the retry does not recompile them from scratch. + * @property proxyAppNotConnected true when the payload had nowhere to land because the proxy + * app was not connected after a launch was already attempted, typed rather than matched on + * [message] because repeating it is the evidence that the app cannot stay up at all (a + * baseline that crashes in `onCreate`), which no edit fixes and no relaunch clears. + */ + data class DeployFailure( + val message: String, + val proxyAppNotConnected: Boolean = false, + ) : BuildOutcome + + /** + * The build pipeline itself broke (daemon died, I/O error) - not the user's code. + * + * @property message what broke, for the status surface and the log. + * @property daemonDied true when the daemon process is gone, so the session must start a new + * one with empty incremental caches before the next build. + */ + data class InfrastructureFailure( + val message: String, + val daemonDied: Boolean = false, + ) : BuildOutcome +} + +/** + * One compiler message, tagged file:line so the status surface can name where it failed. + * + * @property severity whether the message failed the build or only warned. + * @property message the compiler's text, unformatted and not localized. + * @property file absolute path of the offending source; null when the compiler named none. + * @property line 1-based line number; null when the compiler named none. + * @property column 1-based column number; null when the compiler named none. + */ +data class BuildDiagnostic( + val severity: Severity, + val message: String, + val file: String? = null, + val line: Int? = null, + val column: Int? = null, +) { + /** How much a diagnostic matters: only [ERROR] fails a build. */ + enum class Severity { ERROR, WARNING } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestrator.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestrator.kt new file mode 100644 index 0000000000..20ab908c58 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestrator.kt @@ -0,0 +1,929 @@ +package org.appdevforall.cotg.quickbuild.domain.reload + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.classify.ChangeClassifier +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Schedules quick builds: at most one in flight, everything else coalesced into a pending set that + * is never lost - a failed build's batch is unioned back, and a Gradle verdict outlives the paths + * that proved it ([stickyInvalidation]). Runs the live-reload path only, escalating anything that + * needs Gradle as [OrchestratorEvent.InvalidationRequired]. Event ORDER holds only when the public + * API and [scope] share a single-threaded dispatcher - wire it that way. + */ +class LiveReloadOrchestrator( + /** Runs each build; called with at most one request in flight, and never for Gradle routes. */ + private val executor: LiveReloadExecutor, + /** Routes each pending set. Its [BuildRoute.FullGradleBuild] verdicts are escalated, not run. */ + private val classifier: ChangeClassifier, + /** + * Where builds are launched. Cancelling it abandons an in-flight build without returning its + * batch to pending, so prefer [onCancelRequested] for a user stop. + */ + private val scope: CoroutineScope, + /** + * Monotonic clock for the e2e timeline's t0, wired to `SystemClock.elapsedRealtime` on device + * so it shares the executor's timebase. + */ + private val now: () -> Long = System::currentTimeMillis, + /** + * Wall clock for the mid-rebuild echo split, epoch millis so it shares a timebase with + * [fileLastModified] - deliberately separate from [now], which is elapsedRealtime on device + * and compares to no file mtime. + */ + private val wallClock: () -> Long = System::currentTimeMillis, + /** Reads a file's mtime (epoch millis, 0 when missing or unreadable); injectable for tests. */ + private val fileLastModified: (File) -> Long = File::lastModified, + /** + * Receives every event, delivered outside the internal lock on the caller's context so a + * handler may call back in; it must not throw, as an exception propagates into the caller. + */ + private val onEvent: (OrchestratorEvent) -> Unit, +) { + private val log = LoggerFactory.getLogger("QB-Orchestrator") + + private val mutex = Mutex() + private var pending: ChangedFiles = ChangedFiles.Known.EMPTY + private var pendingForced = false + + /** + * Set by a Quick Build tap and nothing else, because it decides whether the user is pulled out + * of the editor into the proxy app - unlike [pendingForced], which the reconnect catch-up also + * sets and a failed build re-arms. A failed build does NOT re-arm this one: the tap was already + * answered, with an error. It never outlives the pending set it asked about, or a later + * automatic save would be reported as something the user asked for. + */ + private var pendingUserInitiated = false + + /** + * A user tap whose save-all wrote something, waiting for the watcher batch those writes will + * produce. Consumed by the first non-empty batch (which then carries the ask as + * [pendingUserInitiated]) or by [consumeUnansweredTap]'s deadline, whichever comes first - + * never both, so the tap is answered exactly once. Cleared wherever [pendingUserInitiated] + * is force-cleared, for the same reason: the ask must not outlive the work it was about. + */ + private var tapAwaitingChanges = false + private var inFlight: InFlightBuild? = null + private var nextBuildId = 1L + private var invalidationReported = false + + /** + * A Gradle verdict the enumerated part of the pending set already demanded, latched before a + * [ChangedFiles.Unknown] collapse erased the paths that proved it. + * + * [ChangedFiles.Unknown] classifies as the FAST daemon path, so a pending `AndroidManifest.xml` + * edit plus a daemon replacement would otherwise compile, relink, deploy and report success + * with the manifest change never absorbed. Cleared only by [onBaselineReset], the build that + * really absorbs it. + */ + private var stickyInvalidation: InvalidationReason? = null + + /** + * When the current pending batch began WAITING for a build - t0 for the build it becomes; null + * means nothing is waiting, so the next arrival stamps it and a later one coalescing in keeps + * the earliest stamp. Null rather than "pending is empty" because a failed build's returned + * batch sits in [pending] waiting on the user, not queueing - charging that think-and-fix time + * to the next build reported a 2.25s save as 197.3s (T16). + */ + private var pendingSince: Long? = null + + /** Changes a running Gradle proxy app rebuild will absorb; restored if it fails. */ + private var awaitingAbsorption: ChangedFiles? = null + + /** + * When the running proxy app rebuild started, epoch millis from [wallClock], for the echo + * split in [absorbEchoesLocked]; meaningful only while [awaitingAbsorption] is non-null. + */ + private var absorptionStartedAtMillis = 0L + + /** Diagnostics of the last CompileError, for the duplicate-follow-up guard. */ + private var lastCompileDiagnostics: List? = null + + /** + * The previous surfaced build's failure, for the repeat-failure escalation. Cleared by any + * success, so only a consecutive run of failures counts. + */ + private var lastFailure: BuildOutcome? = null + + /** How many surfaced builds in a row have failed with exactly [lastFailure]. */ + private var identicalFailures = 0 + + /** + * Spent once the repeat-failure escalation has asked for a proxy app rebuild, and cleared + * only by a success or a completed rebuild. + * + * This is the loop guard. A failed proxy app rebuild leaves the latch spent, so the next + * identical failure escalates nothing: the session degrades to plain build failures rather + * than rebuilding on every save forever. + */ + private var repeatFailureEscalated = false + + /** + * Spent once a repeating aapt2 rejection has been reported as blocking, and cleared by the + * same things that clear the failure tally - a success or a fresh baseline. + * + * One report per streak, because the message asks the user to do something: repeating it on + * every save would train them to dismiss it. + */ + private var relinkStuckReported = false + + /** + * Spent once "the proxy app will not stay up" has been reported, and cleared by the same + * things that clear the failure tally. + * + * One report per streak: the message asks the user to restart the session, so repeating it + * on every save would train them to dismiss it. + */ + private var proxyAppWontStayUpReported = false + + /** Requested background warm compile (post-provisioning); dropped once any real build runs. */ + private var pendingWarmCompile = false + + private data class InFlightBuild( + val buildId: Long, + val batch: ChangedFiles, + val forced: Boolean, + val autoFollowUp: Boolean, + val route: BuildRoute, + /** + * Mutable because a tap landing MID-BUILD is satisfied by this build's deploy + * rather than by a second one: the tap has nothing to add except the ask itself. + */ + var userInitiated: Boolean = false, + /** + * Cancellation handle. [onCancelRequested] leaves a warm compile alone, since the user + * never asked for it; a proxy app rebuild supersedes and cancels any route. + */ + var job: Job? = null, + ) + + /** + * A watcher/editor save event. [ChangedFiles.Unknown] forces a full recompile. + * + * @param changes the coalesced batch; it is unioned onto whatever is already pending, so a + * save landing mid-build is never lost. + */ + suspend fun onFilesChanged(changes: ChangedFiles) { + withEvents { events -> + val remainder = absorbEchoesLocked(changes) + // A batch the rebuild fully absorbed queues nothing, so it must not stamp the + // queue clock - the rebuild's minutes are not the next build's wait. + if (awaitingAbsorption != null && remainder.isEmpty) return@withEvents + markBatchArrivalLocked() + pending = unionPendingLocked(pending, remainder) + if (tapAwaitingChanges && !pending.isEmpty) { + // The batch the tap's save-all promised has arrived; the build it produces + // answers the tap, so its deploy may bring the proxy app forward. + tapAwaitingChanges = false + pendingUserInitiated = true + } + maybeStartBuildLocked(events) + } + } + + /** + * An explicit Quick Build tap, or the reconnect catch-up: decide how the ask is answered. + * + * A user tap never forces a blind rebuild (the F7 echo fix): with work already pending it + * starts a correctly-routed build whose deploy answers the tap; with nothing pending and + * [expectChanges] set it arms the tap on the watcher batch the save-all's writes will + * deliver; with nothing pending and nothing written the caller switches immediately - the + * deployed app is already current. Only the non-user reconnect catch-up still forces + * ([BuildRequest.forced]): the app is provably behind and there is no changed-set to route, + * and a failed forced build re-arms the flag so the eventual retry is forced too. + * + * @param userInitiated whether a human asked - only a tap passes true, since the reconnect + * catch-up would otherwise drag the user into the proxy app unprompted. + * @param expectChanges tap-only: the tap's save-all wrote at least one file, so a watcher + * batch is expected within the coalescer window. + * @return how the ask gets answered; see [LiveReloadRequestOutcome]. + */ + suspend fun onLiveReloadRequested( + userInitiated: Boolean = true, + expectChanges: Boolean = false, + ): LiveReloadRequestOutcome { + var outcome = LiveReloadRequestOutcome.SWITCH_NOW + withEvents { events -> + when { + !userInitiated -> { + // Reconnect catch-up: the app runs an old generation and no changed-set + // names why, so only a forced blind rebuild repairs it. + markBatchArrivalLocked() + pendingForced = true + outcome = LiveReloadRequestOutcome.AWAITS_DEPLOY + maybeStartBuildLocked(events) + } + + !pending.isEmpty -> { + // Accumulated work: build it now, routed by the classifier as any save + // would be; the deploy answers the tap. + markBatchArrivalLocked() + pendingUserInitiated = true + outcome = LiveReloadRequestOutcome.AWAITS_DEPLOY + maybeStartBuildLocked(events) + } + + expectChanges -> { + // The tap's save-all wrote something, so its watcher batch is already on + // the way (the coalescer emits within 250 ms of the last event). Arm the + // tap on that batch instead of building an empty set behind it; the + // caller runs the deadline fallback for the case where every written + // file was watcher-irrelevant and no batch ever comes. Deliberately no + // queue-clock stamp: if no batch comes, a stamp here would charge the + // dead wait to the next unrelated save's build (the T16 shape). + tapAwaitingChanges = true + outcome = LiveReloadRequestOutcome.AWAITS_CHANGES + } + + else -> { + // Nothing written and nothing pending: the deployed app is current, so + // the tap is answered by switching to it and no build runs at all. + outcome = LiveReloadRequestOutcome.SWITCH_NOW + } + } + } + return outcome + } + + /** + * Disarms a tap still waiting for its save-all's watcher batch and says whether it was + * waiting - the deadline half of the arm-on-batch tap protocol. + * + * Called by the session manager's fallback timer. True means no batch arrived (the save-all + * wrote only watcher-irrelevant files, e.g. a `.md`), so the caller answers the tap by + * switching now; false means a batch already consumed the tap and its build's deploy + * answers it, so the caller must do nothing - either way, exactly once. + */ + suspend fun consumeUnansweredTap(): Boolean = + mutex.withLock { + val wasArmed = tapAwaitingChanges + tapAwaitingChanges = false + wasArmed + } + + /** + * Makes the in-flight build the answer to a Quick Build tap that landed while it was + * already running, instead of queueing a second build behind the same work. + * + * @return false when there is nothing to mark - no build in flight, or a warm compile, which + * deploys nothing, so the caller must issue a real request rather than let the tap vanish. + */ + suspend fun markInFlightUserInitiated(): Boolean = + mutex.withLock { + val flight = inFlight + if (flight == null || flight.route is BuildRoute.WarmCompile) { + false + } else { + flight.userInitiated = true + // The request already left with userInitiated false, so the executor has to + // hear about the promotion separately or this build's deploy would still + // refuse to open a closed app - and the tap would do nothing at all. + executor.markCurrentBuildUserInitiated() + true + } + } + + /** + * Abandons the in-flight build on a stop tap, so nothing it produces is deployed or rendered, and + * returns its batch to [pending] for the next save or tap to rebuild. + * + * Two limits: the daemon has no cancel op, so the compile runs to completion unheard and may + * delay the next build; and a stop in the deploy's own scheduler turn can report a cancel for a + * payload the proxy app already took, leaving the status line one generation behind. + * + * @return true when a build was abandoned; false when there was nothing to cancel or it was a + * warm compile the user never asked for, on which the caller must report no cancellation. + */ + suspend fun onCancelRequested(): Boolean { + var cancelled = false + mutex.withLock { + val flight = inFlight ?: return@withLock + if (flight.route is BuildRoute.WarmCompile) return@withLock + inFlight = null + // A stop withdraws the ask, so neither the abandoned build's forced flag nor a tap + // queued behind it - answered or still armed - may survive to redeploy later. + pendingForced = false + pendingUserInitiated = false + tapAwaitingChanges = false + // And the abandoned build's t0 goes with it: the returning batch now waits on the + // user, not on a queue, so the next arrival stamps its own. A mid-build save already + // owns the clock and keeps it - that save really did queue behind this build. + if (pending.isEmpty) pendingSince = null + pending = unionPendingLocked(flight.batch, pending) + flight.job?.cancel() + cancelled = true + } + if (cancelled) log.info("Quick build cancelled by the user") + return cancelled + } + + /** + * Requests a background warm compile, called by the session manager once provisioning + * goes live, so the first save does not pay the compiler warm-up. + * + * Lowest priority by construction: any real work makes it redundant, since the daemon's + * first real build compiles the full source set anyway, so it is dropped rather than + * queued behind user work. + */ + suspend fun onWarmCompileRequested() { + withEvents { events -> + if (inFlight != null) return@withEvents + pendingWarmCompile = true + maybeStartBuildLocked(events) + } + } + + /** + * Recovers from a fresh daemon process replacing a dead one (crash, trim-memory teardown, + * deliberate restart). Its caches are empty, but the watcher never stopped, so the pending set + * is still trustworthy. + * + * With nothing pending it re-warms the daemon without deploying - the proxy app already runs the + * last generation. With work pending the whole baseline goes dirty and the next build deploys. + */ + suspend fun onDaemonReplaced() { + withEvents { events -> + if (inFlight == null && pending.isEmpty && !pendingForced) { + pendingWarmCompile = true + } else { + markBatchArrivalLocked() + pending = unionPendingLocked(pending, ChangedFiles.Unknown) + } + maybeStartBuildLocked(events) + } + } + + /** + * Marks the whole baseline dirty after an external full Gradle build (a Standard Run) + * moved generated inputs and classpath jars under `build/`, which the watcher cannot see. + * + * Starts no build of its own: the next save or tap recompiles everything from current + * disk, so the hand-back can never serve code compiled against the old baseline. + */ + suspend fun onBaselineUntrusted() { + mutex.withLock { + // Deliberately does not stamp the queue clock: nothing is waiting for a build here, + // so a clock started now would charge the gap until the user's next save to that + // save's queue. Whatever was already queueing keeps its own stamp. + pending = unionPendingLocked(pending, ChangedFiles.Unknown) + } + } + + /** + * Hands the pending set over to a full Gradle proxy app rebuild the session manager just started. + * + * Everything pending, plus any in-flight build's batch (those files are on disk, so Gradle reads + * them), is marked absorbed-in-progress and the in-flight build is cancelled. A batch arriving + * after this call is split by mtime against the rebuild's start ([absorbEchoesLocked]): files + * already on disk when Gradle read the tree are absorbed too, newer ones count as not absorbed. + * Unlike a stop, this emits nothing - a rebuild superseded the work rather than the user asking + * for a cancellation. + */ + suspend fun onProxyAppRebuildStarted() { + mutex.withLock { + val superseded = inFlight + absorptionStartedAtMillis = wallClock() + awaitingAbsorption = unionPendingLocked(superseded?.batch ?: ChangedFiles.Known.EMPTY, pending) + pending = ChangedFiles.Known.EMPTY + // Gradle owns this batch now, and a rebuild runs for minutes. Keeping the clock would + // charge all of it to whichever build picked the batch back up if the rebuild failed. + pendingSince = null + pendingForced = false + // The tap this recorded asked about the very set Gradle is now absorbing, so that + // build answers it. Left armed, it would tag some later unrelated save as the user's + // ask and pull them out of the editor into the proxy app. Same for a tap still + // waiting on its batch: the rebuild reads the tap's saves off disk anyway. + pendingUserInitiated = false + tapAwaitingChanges = false + inFlight = null + // Nulling inFlight only discards the late RESULT; the coroutine runs on and would + // deploy a payload compiled against the old baseline into an app Gradle is + // reinstalling. State settles first, then the job dies - as in onCancelRequested. + superseded?.job?.cancel() + } + } + + /** + * Completes a proxy app rebuild: drops the absorbed changes and immediately builds + * anything that arrived mid-rebuild. + * + * Calling this without [onProxyAppRebuildStarted] is a protocol violation - the fallback + * drops everything pending, which risks a stale proxy app, hence the warning. + */ + suspend fun onBaselineReset() { + withEvents { events -> + if (awaitingAbsorption == null) { + log.warn("onBaselineReset without onProxyAppRebuildStarted; dropping pending set") + val superseded = inFlight + pending = ChangedFiles.Known.EMPTY + pendingSince = null + pendingForced = false + // Dropped with the set it asked about; see onProxyAppRebuildStarted. + pendingUserInitiated = false + tapAwaitingChanges = false + inFlight = null + superseded?.job?.cancel() + } + awaitingAbsorption = null + invalidationReported = false + // The Gradle build the latch demanded has now run and absorbed the change. + stickyInvalidation = null + lastCompileDiagnostics = null + // A fresh baseline is a genuinely new situation, so a later stuck relink gets its + // own escalation. Deliberately NOT cleared by onProxyAppRebuildFailed, which is + // what keeps a failing rebuild from being re-requested. + clearFailureTallyLocked() + maybeStartBuildLocked(events) + } + } + + /** + * Returns the held batch to pending after a failed proxy app rebuild - nothing was + * absorbed. + * + * Emits no event: re-reporting invalidation would loop the failing fallback, so the next + * save re-triggers it once the user has fixed the problem. + */ + suspend fun onProxyAppRebuildFailed() { + mutex.withLock { + awaitingAbsorption?.let { held -> + pending = unionPendingLocked(held, pending) + } + awaitingAbsorption = null + invalidationReported = false + // stickyInvalidation deliberately survives: nothing was absorbed, so the change that + // demanded Gradle is still unabsorbed and must not fall back to the fast path. + } + } + + /** + * Splits a batch arriving while a proxy app rebuild is absorbing the pending set. + * + * A file whose mtime predates the rebuild's start was on disk before Gradle read the tree, + * so the rebuild absorbs it - typically the tap's own save echo, whose debounce lands it + * just after [onProxyAppRebuildStarted]; stranded in [pending] instead, [onBaselineReset] + * would resurface it as a spurious invalidation. Everything else stays pending, because a + * real mid-rebuild edit must still build once the baseline lands: files modified after the + * start, files with no readable mtime (nothing proves they predate the read), removals (no + * mtime left to date them), and [ChangedFiles.Unknown]. + * + * The absorbed part joins [awaitingAbsorption] via [ChangedFiles.plus], NOT + * [unionPendingLocked]: this rebuild IS the Gradle build these files would demand, so + * latching a sticky verdict from them would re-report the invalidation it is resolving. + * A failed rebuild restores them with the rest of the held set ([onProxyAppRebuildFailed]). + * + * @param changes the arriving batch. + * @return what is left for [pending]; [changes] unchanged when no rebuild is running. + */ + private fun absorbEchoesLocked(changes: ChangedFiles): ChangedFiles { + val held = awaitingAbsorption ?: return changes + if (changes !is ChangedFiles.Known) return changes + val absorbed = + changes.files.filterTo(mutableSetOf()) { file -> + fileLastModified(file) in 1..absorptionStartedAtMillis + } + if (absorbed.isEmpty()) return changes + awaitingAbsorption = held + ChangedFiles.Known(absorbed) + return ChangedFiles.Known(changes.files - absorbed, changes.removed) + } + + /** + * Stamps [pendingSince] only when nothing is already waiting, so a coalesced build's t0 is + * its earliest still-waiting change - the latency the user actually waits. + * + * Called from the paths that give a build something to wait for. A path that only marks work + * stale without making anything queue must not call it; see [onBaselineUntrusted]. + */ + private fun markBatchArrivalLocked() { + if (pendingSince == null) pendingSince = now() + } + + /** + * Unions two changed-sets, latching into [stickyInvalidation] any Gradle verdict an + * enumerated side already demanded when the result collapses to [ChangedFiles.Unknown]. + * + * Preserving the verdict rather than re-routing Unknown keeps the fast daemon path intact for a + * plain Unknown - "recompile everything from current disk", which is what an untrusted baseline + * means and why it must not become a full Gradle build. + * + * @param older the batch already held. + * @param newer the arriving batch, whose per-path verdict wins - see [ChangedFiles.plus]. + * @return the reconciled union, unchanged from [ChangedFiles.plus]. + */ + private fun unionPendingLocked( + older: ChangedFiles, + newer: ChangedFiles, + ): ChangedFiles { + val union = older + newer + if (union is ChangedFiles.Unknown) { + latchInvalidationLocked(older) + latchInvalidationLocked(newer) + } + return union + } + + /** + * Latches [side]'s Gradle verdict, if it has one, so the collapse cannot hide it. + * + * The first reason latched wins: every [BuildRoute.FullGradleBuild] reason drives the same + * proxy app rebuild, so a later one would only change the message. + * + * @param side one operand of a union that collapsed to Unknown, skipped unless it is a + * non-empty enumerated set, since only those name paths a classifier can read. + */ + private fun latchInvalidationLocked(side: ChangedFiles) { + if (stickyInvalidation != null) return + if (side !is ChangedFiles.Known || side.isEmpty) return + val route = classifier.classify(side) + if (route is BuildRoute.FullGradleBuild) stickyInvalidation = route.reason + } + + private suspend inline fun withEvents(block: (MutableList) -> Unit) { + val events = mutableListOf() + mutex.withLock { block(events) } + events.forEach(onEvent) + } + + /** + * Starts a build when one can run now: nothing in flight, no Gradle rebuild, work to do. + * + * @param events sink for events to emit once the lock is released; this call appends a + * BuildStarted or an InvalidationRequired, or nothing when no build can start. + * @param autoFollowUp true when chaining off a build that just finished rather than off a + * user save, which is what lets a repeat failure be reported as diagnostics-unchanged. + */ + private fun maybeStartBuildLocked( + events: MutableList, + autoFollowUp: Boolean = false, + ) { + if (inFlight != null) return + // Quick builds are suspended while a proxy app rebuild runs, or they would race + // Gradle against a half-reset baseline. Saves accumulate and build on onBaselineReset. + if (awaitingAbsorption != null) return + val latched = stickyInvalidation + if (latched == null && pending.isEmpty && !pendingForced) { + if (pendingWarmCompile) startWarmCompileLocked(events) + return + } + // Real work makes a still-pending warm compile redundant, because a code-bearing route + // compiles the full source set on its first run. A resources-only route does not + // actually warm the compiler, so clearing the flag here costs that project one cold + // compile on a later save - a missed optimization, not a correctness problem. + pendingWarmCompile = false + + // A latched verdict outranks the pending set's own route: the paths that proved it were + // erased by an Unknown collapse, so classify() can no longer see them and would pick the + // fast path for a change the live reload path cannot absorb. + val route = latched?.let { BuildRoute.FullGradleBuild(it) } ?: classifier.classify(pending) + if (route is BuildRoute.FullGradleBuild) { + // The live reload path can't absorb this; hand off to the session manager once. + // Pending is kept: it documents what the proxy app rebuild will absorb. + if (!invalidationReported) { + invalidationReported = true + events += OrchestratorEvent.InvalidationRequired(route.reason) + } + return + } + + val batch = pending + val forced = pendingForced + val userInitiated = pendingUserInitiated + // A batch with no clock was not queueing - it is a failed build's batch that has been + // sitting on the user, picked up by a path that starts a build without an arrival of its + // own. Its t0 is this build's own start, which reports the wait as the zero it was. + val triggeredAtMillis = pendingSince ?: now() + pending = ChangedFiles.Known.EMPTY + pendingSince = null + pendingForced = false + pendingUserInitiated = false + val buildId = nextBuildId++ + val flight = + InFlightBuild(buildId, batch, forced, autoFollowUp, route, userInitiated = userInitiated) + inFlight = flight + events += OrchestratorEvent.BuildStarted(buildId, route, batch) + + val request = + BuildRequest( + buildId = buildId, + changes = batch, + route = route, + forced = forced, + triggeredAtMillis = triggeredAtMillis, + userInitiated = userInitiated, + ) + // Assigned while still holding the lock, so a cancel can never see a null handle for a + // build that is already running. Nothing suspends in between, and the launched + // coroutine cannot run before this frame yields. + flight.job = launchBuild(buildId, request) + } + + /** + * Starts the background warm compile. + * + * Its batch is empty because it represents no user changes, so a failed warm compile + * unions nothing back into pending; the request's changes are [ChangedFiles.Unknown] so + * the executor still compiles everything. + * + * @param events sink for the one BuildStarted this always appends, drained after the lock. + */ + private fun startWarmCompileLocked(events: MutableList) { + pendingWarmCompile = false + val buildId = nextBuildId++ + val route = BuildRoute.WarmCompile + val flight = + InFlightBuild(buildId, ChangedFiles.Known.EMPTY, forced = false, autoFollowUp = false, route = route) + inFlight = flight + // The EVENT batch is Unknown, matching the request below, so a metrics sink reports + // "unknown size" rather than zero files. It deliberately diverges from the flight's + // empty batch above - don't assume the two match for a warm compile. + events += OrchestratorEvent.BuildStarted(buildId, route, ChangedFiles.Unknown) + val request = + BuildRequest( + buildId = buildId, + changes = ChangedFiles.Unknown, + route = route, + forced = false, + triggeredAtMillis = now(), + ) + flight.job = launchBuild(buildId, request) + } + + private fun launchBuild( + buildId: Long, + request: BuildRequest, + ): Job = + scope.launch { + val outcome = + try { + executor.execute(request) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + log.error("Quick build #{} threw instead of reporting an outcome", buildId, e) + BuildOutcome.InfrastructureFailure(e.message ?: e.javaClass.name) + } + onBuildFinished(buildId, outcome) + } + + /** + * Reports one build's outcome and either follows it up or returns its batch to pending. + * + * @param buildId which build is reporting; when it no longer matches the in-flight build a + * baseline reset superseded it, and the result is discarded instead of rendered. + * @param outcome what the executor returned, or a synthesized InfrastructureFailure when it + * threw instead of reporting one. + */ + private suspend fun onBuildFinished( + buildId: Long, + outcome: BuildOutcome, + ) { + withEvents { events -> + val flight = inFlight + if (flight == null || flight.buildId != buildId) { + // Superseded (a baseline reset raced this build) - discard, never render. + log.info("Discarding stale result of superseded quick build #{}", buildId) + return@withEvents + } + inFlight = null + + when (outcome) { + is BuildOutcome.Success -> { + lastCompileDiagnostics = null + clearFailureTallyLocked() + events += + OrchestratorEvent.BuildSucceeded( + buildId, + outcome, + flight.route, + userInitiated = flight.userInitiated, + ) + // Saves that landed mid-build start the coalesced follow-up now. + maybeStartBuildLocked(events, autoFollowUp = true) + } + + else -> { + val newSavesArrivedMidBuild = !pending.isEmpty || pendingForced + pending = flight.batch + pending + // The dead attempt's t0 must not outlive it: its batch is back in pending but + // waiting on the user, not queueing, which is not latency this loop owes. A + // save that landed MID-build did genuinely queue behind this one, so its + // stamp is already the pending clock and wins. + if (!newSavesArrivedMidBuild) pendingSince = null + pendingForced = pendingForced || flight.forced + // pendingUserInitiated is deliberately NOT re-armed: the tap was already + // answered, with the failure. The save that fixes the code is not a new + // ask, so it must not drag the user out of the editor (see the field). + + val diagnostics = (outcome as? BuildOutcome.CompileError)?.diagnostics + val relinkStuck = + flight.route !is BuildRoute.WarmCompile && + diagnostics != null && + diagnostics == lastCompileDiagnostics && + !relinkStuckReported && + blocksEveryBuild(diagnostics) + if (relinkStuck) relinkStuckReported = true + // The same shape as relinkStuck, for the deploy half: a second not-connected + // deploy running proves the proxy app cannot stay up long enough to receive + // anything. No edit reaches it (the fix compiles fine and has nowhere to + // land) and "relaunch to reconnect" just restarts the crash, so the only + // true remedy is a fresh proxy app build. + val proxyAppWontStayUp = + flight.route !is BuildRoute.WarmCompile && + (outcome as? BuildOutcome.DeployFailure)?.proxyAppNotConnected == true && + outcome == lastFailure && + !proxyAppWontStayUpReported + if (proxyAppWontStayUp) proxyAppWontStayUpReported = true + // A warm compile's failure is never surfaced, so priming + // lastCompileDiagnostics from it would let the next real build's identical + // failure count as a repeat of an error the user never saw. + if (diagnostics != null && flight.route !is BuildRoute.WarmCompile) { + lastCompileDiagnostics = diagnostics + } + events += + OrchestratorEvent.BuildFailed( + buildId, + outcome, + flight.route, + relinkStuck, + proxyAppWontStayUp, + ) + + if (recordFailureLocked(flight.route, outcome)) { + // The live reload path cannot clear this on its own and the batch is back + // in pending, so every later save would re-fail identically - hand the + // set to Gradle instead. No follow-up build starts here: the session + // manager is about to call onProxyAppRebuildStarted, and + // invalidationReported stops a classifier verdict landing in the same + // window from launching a second rebuild over it. + repeatFailureEscalated = true + identicalFailures = 0 + invalidationReported = true + log.warn( + "Quick build #{} failed identically twice running ({}); escalating to a proxy app rebuild", + buildId, + outcome, + ) + events += + OrchestratorEvent.InvalidationRequired(InvalidationReason.RELOAD_PIPELINE_FAILED) + } else if (newSavesArrivedMidBuild) { + // A mid-build save may be the fix; rebuild from the accumulated set. + maybeStartBuildLocked(events, autoFollowUp = true) + } + } + } + } + } + + /** + * Tallies one failed build and says whether the live reload path cannot recover on its own. + * + * Only a failure that is NOT the user's own code counts: a compile error is theirs to fix and + * auto-escalating one would drop the whole session to Idle, a daemon death has its own respawn + * recovery ([onDaemonReplaced]), and [BuildOutcome.RequiresProxyAppRebuild] escalates itself. + * What is left fails for a reason no edit can reach, so the same failure twice running is the + * evidence - the second build ran against whatever changed in between and failed anyway. + * + * @param route the failed build's route; a warm compile is never surfaced, so it never + * escalates and never contributes to the tally. + * @param outcome how the build failed; compared whole, so any difference restarts the tally. + * @return true when this failure should escalate to a proxy app rebuild - at most once, + * until a success or a completed rebuild clears the latch. + */ + private fun recordFailureLocked( + route: BuildRoute, + outcome: BuildOutcome, + ): Boolean { + if (route is BuildRoute.WarmCompile) return false + identicalFailures = if (outcome == lastFailure) identicalFailures + 1 else 1 + lastFailure = outcome + val pipelineFault = outcome is BuildOutcome.InfrastructureFailure && !outcome.daemonDied + return pipelineFault && + identicalFailures >= ESCALATE_AFTER_IDENTICAL_FAILURES && + !repeatFailureEscalated + } + + /** + * Whether these diagnostics will fail every later build until the user fixes them, whatever + * they save next - the "stuck relink" shape. True only for an aapt2 rejection, recognised by + * every error naming a resource file: the relink links the whole `res/` tree from disk rather + * than the changed set, so once a resource is unlinkable even a pure-code save fails + * identically. A kotlinc error names the file the user is editing, so it is excluded. + * + * @param diagnostics the failed build's diagnostics, warnings included. + * @return true when there is at least one error and every error names a resource path. + */ + private fun blocksEveryBuild(diagnostics: List): Boolean { + val errors = diagnostics.filter { it.severity == BuildDiagnostic.Severity.ERROR } + return errors.isNotEmpty() && + errors.all { it.file != null && ChangeClassifier.namesResource(File(it.file)) } + } + + /** Forgets the failure streak and re-arms the escalation; the pipeline works again. */ + private fun clearFailureTallyLocked() { + lastFailure = null + identicalFailures = 0 + repeatFailureEscalated = false + relinkStuckReported = false + proxyAppWontStayUpReported = false + } + + private companion object { + /** + * How many identical consecutive pipeline failures escalate to a proxy app rebuild. + * Two, so a one-off (a dropped RPC, a transient IO error) costs a retry rather than a + * full Gradle build. + */ + const val ESCALATE_AFTER_IDENTICAL_FAILURES = 2 + } +} + +/** How [LiveReloadOrchestrator.onLiveReloadRequested] answers the ask it was handed. */ +enum class LiveReloadRequestOutcome { + /** Nothing to build: the caller answers a tap itself, immediately. */ + SWITCH_NOW, + + /** A build owns the ask; its deploy (or its failure) answers it. */ + AWAITS_DEPLOY, + + /** + * The tap is armed on the save-all's incoming watcher batch; the caller must run the + * deadline fallback via [LiveReloadOrchestrator.consumeUnansweredTap]. + */ + AWAITS_CHANGES, +} + +/** What the orchestrator tells its host about a build. */ +sealed interface OrchestratorEvent { + /** + * A build just started; [changes] is the batch it took. + * + * @property buildId identifies this build in the later succeeded/failed event. + * @property route what the classifier decided, which says which steps will run. + * @property changes the batch moved out of pending into this build, reported as + * [ChangedFiles.Unknown] for a warm compile even though it carries no user changes. + */ + data class BuildStarted( + val buildId: Long, + val route: BuildRoute, + val changes: ChangedFiles, + ) : OrchestratorEvent + + /** + * A build deployed successfully. + * + * @property buildId the id of the [BuildStarted] this closes. + * @property result the executor's outcome, carrying the generation now live. + */ + data class BuildSucceeded( + val buildId: Long, + val result: BuildOutcome.Success, + /** What the build was for - a [BuildRoute.WarmCompile] success deployed nothing. */ + val route: BuildRoute, + /** + * True when this build answers a Quick Build tap, so the proxy app should be brought + * forward as the deploy lands; false for a build a file write triggered. + */ + val userInitiated: Boolean = false, + ) : OrchestratorEvent + + /** + * A build did not deploy. + * + * @property buildId the id of the [BuildStarted] this closes. + * @property outcome how it failed; the batch has already returned to pending, so the next + * save rebuilds it. + * @property relinkStuck true when a repeating aapt2 rejection is now blocking every build + * whatever the user saves, so the host should say so - set at most once per streak, until a + * success or a fresh baseline (see `LiveReloadOrchestrator.blocksEveryBuild`). + * @property proxyAppWontStayUp true when a second not-connected deploy running proves the proxy + * app cannot stay alive to receive a payload, which no edit reaches and no relaunch clears, + * so the host should offer Restart session - set at most once per streak. + */ + data class BuildFailed( + val buildId: Long, + val outcome: BuildOutcome, + /** What the build was for - a [BuildRoute.WarmCompile] failure is not user-visible. */ + val route: BuildRoute, + val relinkStuck: Boolean = false, + val proxyAppWontStayUp: Boolean = false, + ) : OrchestratorEvent + + /** + * The changed-set needs a real Gradle build; the session manager owns the fallback. + * + * @property reason why the live reload path cannot absorb it, emitted once per pending set so + * that a second save of the same kind does not re-report it. + */ + data class InvalidationRequired( + val reason: InvalidationReason, + ) : OrchestratorEvent +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/README.md b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/README.md new file mode 100644 index 0000000000..9af0692fa2 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/README.md @@ -0,0 +1,13 @@ +# `domain/reload/` - the live-reload decision layer + +Pure-JVM types that decide what one quick build should do to the running proxy app: whether to hot-swap or restart, which generation the payload carries, and whether an install may proceed. No Android. The `LiveReloadOrchestrator` schedules builds (at most one in flight, everything else coalesced into a never-lost pending set); `DeployPolicy` decides restart vs recreate from the baseline's component facts. + +| File | Purpose | +| --- | --- | +| [`LiveReloadOrchestrator.kt`](LiveReloadOrchestrator.kt) | Schedules builds single-flight, coalesces pending changes, escalates Gradle-needing routes, and emits `OrchestratorEvent`s; also defines `OrchestratorEvent`. | +| [`LiveReloadExecutor.kt`](LiveReloadExecutor.kt) | Interface that runs one build end to end; defines `BuildRequest`, `BuildOutcome`, and `BuildDiagnostic`. | +| [`DeployPolicy.kt`](DeployPolicy.kt) | Restarts every code-bearing deploy when the app declares a service, provider or custom `Application`, since the payload redefines those classes whatever the edit touched; defines `DeployDecision`. | +| [`ComponentInfo.kt`](ComponentInfo.kt) | One manifest component the proxy-app build recorded; `ComponentKind` and the `RESTART_SENSITIVE_KINDS` set. | +| [`ClassHeader.kt`](ClassHeader.kt) | Parses a class file's name/superclass/interfaces via a constant-pool walk. Currently unused - kept for the in-place-redefinition follow-up, which needs per-class facts again. | +| [`GenerationTracker.kt`](GenerationTracker.kt) | Hands out monotonically increasing generation numbers, persisted before use; defines the `GenerationStore` port. | +| [`RealIdInstall.kt`](RealIdInstall.kt) | Decides when installing under the project's real applicationId needs clobber confirmation or a signature refusal. | diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/RealIdInstall.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/RealIdInstall.kt new file mode 100644 index 0000000000..13e4518511 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/RealIdInstall.kt @@ -0,0 +1,102 @@ +package org.appdevforall.cotg.quickbuild.domain.reload + +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage + +/** + * Decides when installing under the project's real applicationId needs the user's confirmation. + * + * Quick Build and Standard Run share one package slot - the real applicationId, with no + * `.quickbuild` suffix - so installing one overwrites the other. Which build occupies it is read + * statelessly from the installed package's `android:appComponentFactory`: matching + * [QUICK_BUILD_APP_COMPONENT_FACTORY] means a Quick Build proxy app, anything else does not. + */ +object RealIdInstall { + /** + * FQN of the Quick Build runtime's AppComponentFactory, the marker identifying an installed + * package as a Quick Build proxy app. + * + * Must stay in sync with the runtime class of the same name and with the value the Gradle + * plugin writes into the manifest (`QuickBuildPlugin.APP_COMPONENT_FACTORY`). + */ + const val QUICK_BUILD_APP_COMPONENT_FACTORY = + "com.itsaky.androidide.quickbuild.runtime.QuickBuildAppComponentFactory" + + /** + * True when the package installed under the real id is a Quick Build proxy app. + * + * @param installedFactory the installed package's `android:appComponentFactory`, or null when + * nothing is installed or the manifest declares none. + * @return true only on an exact match with [QUICK_BUILD_APP_COMPONENT_FACTORY]; null and any + * other factory both mean "not ours". + */ + fun isQuickBuildProxyApp(installedFactory: String?): Boolean = installedFactory == QUICK_BUILD_APP_COMPONENT_FACTORY + + /** + * Whether tapping Quick Build must confirm a clobber first. + * + * Only when a different build occupies the slot; a fresh slot or Quick Build's own proxy + * app installs without a prompt. + * + * @param realAppInstalled whether anything is installed under the project's real applicationId. + * @param installedFactory that package's `android:appComponentFactory`, or null when unreadable + * or undeclared - an unreadable one counts as somebody else's build. + * @return true when the user must confirm overwriting a non-Quick-Build package. + */ + fun quickBuildNeedsClobberConfirm( + realAppInstalled: Boolean, + installedFactory: String?, + ): Boolean = realAppInstalled && !isQuickBuildProxyApp(installedFactory) + + /** + * Whether a Standard Run must confirm a clobber first. + * + * Only when a Quick Build proxy app occupies the slot; over a normal app or nothing, + * Standard Run behaves as always. + * + * @param installedFactory the installed package's `android:appComponentFactory`, or null when + * nothing is installed. + * @return true when a Quick Build proxy app is about to be overwritten, which also ends its + * session. + */ + fun standardRunNeedsClobberConfirm(installedFactory: String?): Boolean = isQuickBuildProxyApp(installedFactory) + + /** + * Refuses to install the proxy app over a real-id package this device's CoGo did not build. + * + * The provisioner's authoritative safety check: an update-install cannot preserve a + * third-party app's data, so the only way past a refusal is a manual uninstall. An + * unreadable cert on either side counts as "cannot prove same origin" and refuses. + * + * @param realApplicationId the project's real applicationId, named back to the user in the + * refusal. + * @param realAppInstalled whether anything occupies that slot; an empty slot always proceeds. + * @param installedCertSha256 signing-cert SHA-256 of the installed package, or null when it + * cannot be read - which refuses. + * @param builtCertSha256 signing-cert SHA-256 of the proxy app about to be installed, or null + * when it cannot be read - which also refuses. + * @return the refusal message, or null to proceed. + */ + fun signatureRefusal( + realApplicationId: String, + realAppInstalled: Boolean, + installedCertSha256: String?, + builtCertSha256: String?, + ): QuickBuildMessage? { + if (!realAppInstalled) return null + if (installedCertSha256 != null && + builtCertSha256 != null && + installedCertSha256.equals(builtCertSha256, ignoreCase = true) + ) { + return null + } + return refusalMessage(realApplicationId) + } + + /** + * The refusal wording: names the reason and the manual way forward. + * + * @param realApplicationId the applicationId to name in the message. + * @return the named refusal; the host owns its wording. + */ + fun refusalMessage(realApplicationId: String): QuickBuildMessage = QuickBuildMessage.ForeignAppInstalled(realApplicationId) +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildMessage.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildMessage.kt new file mode 100644 index 0000000000..0d7116cf45 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildMessage.kt @@ -0,0 +1,121 @@ +package org.appdevforall.cotg.quickbuild.domain.session + +/** + * A failure the session needs the host to flash, named rather than written. + * + * Same reason as [QuickBuildNotice]: this module has no `R`, so copy written here would ship + * untranslated into an IDE that has a dozen locales. Each case names a situation and carries only + * the values the wording needs; the host maps it to a string resource. [Literal] is the deliberate + * exception - text nothing can translate (a PackageManager verdict, an exception message) or that + * the host already resolved from its own resources. + */ +sealed interface QuickBuildMessage { + /** + * Final text, passed through untouched. + * + * @property text host-resolved copy, or an opaque detail from Android or a thrown exception - + * never a sentence written in this module, which is what the named cases are for. + */ + data class Literal( + val text: String, + ) : QuickBuildMessage + + /** + * The OS wants the reinstall confirmed but no dialog could be shown, because CoGo was not + * in the foreground to host it. Returning to CoGo is what re-prompts. + */ + data object ReinstallReturnToCoGo : QuickBuildMessage + + /** The reinstall dialog was shown and the user declined it. */ + data object ReinstallDeclined : QuickBuildMessage + + /** + * The reinstall dialog was shown and went unanswered until the installer timed out. + * + * @property seconds how long it waited, in whole seconds, because the wording names it + */ + data class ReinstallTimedOut( + val seconds: Long, + ) : QuickBuildMessage + + /** + * A reinstall retry could not get the Gradle slot, usually to the project sync that the + * invalidating edit triggered. The app still needs its reinstall. + */ + data object ReinstallWaitingForGradle : QuickBuildMessage + + /** The installer could not even launch the install. */ + data object InstallCouldNotStart : QuickBuildMessage + + /** The install ran and the OS reported a failure with nothing more specific to say. */ + data object InstallFailed : QuickBuildMessage + + /** + * The install reported success but PackageManager will not resolve the package, so there + * is no uid to open the deploy channel with. + * + * @property packageName the proxy app package that cannot be resolved + */ + data class InstalledButUnresolvable( + val packageName: String, + ) : QuickBuildMessage + + /** + * The app already installed under the project's own applicationId was built by something + * other than this device's CoGo, so Quick Build would have to delete it and its data. + * + * @property applicationId the occupied applicationId, named so the user knows what to back + * up before uninstalling it themselves + */ + data class ForeignAppInstalled( + val applicationId: String, + ) : QuickBuildMessage + + /** The proxy app rebuild failed with no more specific cause to report. */ + data object RebuildFailed : QuickBuildMessage + + /** + * App storage is too tight to hold the build's intermediates. Checked up front so this + * fails in seconds rather than minutes into a build. + * + * @property requiredMb what the guard wants free, in MB + * @property availableMb what is actually free, in MB + */ + data class NotEnoughStorage( + val requiredMb: Long, + val availableMb: Long, + ) : QuickBuildMessage + + /** + * The scratch tree could not be created, so the pipeline has nowhere to write. + * + * @property path the location that could not be created, which is diagnostic but is the + * only thing that distinguishes one of these from another + */ + data class ScratchDirUnavailable( + val path: String, + ) : QuickBuildMessage + + /** The compile daemon refused the configuration it was started with. */ + data object DaemonRejectedConfiguration : QuickBuildMessage + + /** + * The compile daemon died and could not be restarted, so the session stays degraded until + * the next tap or a session restart retries. + * + * @property detail the respawn failure's own text, which is diagnostic rather than + * translatable + */ + data class DaemonRestartFailed( + val detail: String, + ) : QuickBuildMessage + + /** + * A Quick Build tap while the compiler is down is retrying the restart. + * + * The tap's own acknowledgement, so that it is never silent. The respawn it triggers can be + * superseded by one already in flight, which reports nothing, and the status reads "restarting + * the compiler" either way - so without this the tap would look ignored. + */ + data object DaemonRestartRetrying : QuickBuildMessage +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildNotice.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildNotice.kt new file mode 100644 index 0000000000..8227196cad --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildNotice.kt @@ -0,0 +1,66 @@ +package org.appdevforall.cotg.quickbuild.domain.session + +/** + * A message the session needs the host to show, named rather than written. + * + * An enum rather than text because the copy lives in the app module's string resources - this + * module has no `R`. Separate from the session's user-message flow, which the host always + * flashes as an ERROR: each notice carries its own tone, so a cancellation the user asked for + * does not read as a failure while a reload that keeps crashing still does. + */ +enum class QuickBuildNotice { + /** A build the user stopped with the stop button (behaviour 5). */ + BUILD_CANCELLED, + + /** + * The proxy app crashed running a deployed generation. + * + * Always a reload, never an ordinary launch crash: the runtime's crash guard reports only while + * a reload is pending. A crash in the user's own new code clears itself, but a payload broken + * for a reason no edit reaches is redeployed by every later reload and crashes the same way - + * so the copy asks for the fix first and names Restart session for when that does not help. + */ + RELOAD_CRASHED, + + /** + * A deploy landed by hot swap in an app that has a live service, provider or custom + * `Application`, so an instance of one keeps calling the PREVIOUS copies of the helper + * classes this build recompiled until it restarts. + * + * Not a failure: the deploy worked and the recreated activity runs the new code. The restart + * closure covers a component's own code and its supertypes, and a hit there restarts the + * process; what it cannot see is a helper class the component merely calls. + */ + STALE_COMPONENT_HELPERS, + + /** + * A save under a test source set (`src/test`, `src/androidTest`, `testFixtures`) was ignored: + * nothing there ships in the variant Quick Build deploys, so no build can carry it. + * + * Not a failure and not something to fix - it says why nothing happened, once per session, so + * the silence does not read as a broken watcher. Every later test save is silent, which is the + * point: the user only needs to learn this once. + */ + TEST_SOURCE_IGNORED, + + /** + * aapt2 keeps rejecting the project's resources, so every save fails on that same error. + * + * The relink links the whole `res/` tree from disk, not the changed set, so an unlinkable + * resource blocks the path outright, even for a pure-code save. The copy asks for the fix first + * and names Restart session for the case no edit clears - a library resource absent from the + * proxy app build's snapshot. Not auto-escalated: aapt2's diagnostics cannot tell the two apart. + */ + RELINK_STUCK, + + /** + * The proxy app cannot stay alive long enough to receive a payload, so every deploy fails + * "not connected" however many times the user relaunches. + * + * The shape is a baseline that crashes at startup, usually because provisioning ran while the + * app's own code was broken. Nothing the user edits reaches it - the fix dexes cleanly and then + * has nowhere to land - so only a fresh proxy app build helps. This is therefore the one notice + * that asks for Restart session outright, raised by the host as a dialog carrying that action. + */ + PROXY_APP_WONT_STAY_UP, +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimeline.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimeline.kt new file mode 100644 index 0000000000..daa0d55061 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimeline.kt @@ -0,0 +1,242 @@ +package org.appdevforall.cotg.quickbuild.domain.telemetry + +/** + * One generation's end-to-end reload timeline: the four timestamps that bound the live-reload + * loop, from the file-watch trigger to new code running in the proxy app. + * + * All four stamps come off one monotonic device clock (`SystemClock.elapsedRealtime`; an + * injected fake in tests), so their differences are meaningful with no cross-process clock + * sync. Absolute values compare only within a single boot - read the deltas, never the stamps. + * + * @property generation the deploy generation this loop delivered; strictly increasing per + * session, and the key a harness joins a row to its build by. + * @property trigger t0: when the earliest change this build coalesced started waiting for a build - + * it stamps an already-settled batch, so the watcher's quiet period sits just before t0 and in no + * duration here, and a batch a failed build handed back re-stamps at the next save rather than + * keeping the dead attempt's t0. + * @property compileDone t1: compile and dex finished, equal to [deploySent] on a route that runs no + * compile, where [compileMillis] then measures relink and packaging instead. + * @property deploySent t2: immediately before the payload goes over the binder deploy channel. + * @property reloadLive t3: the proxy app confirmed the new code is live - a hot-swap + * `reportReloaded` from the recreated activity's onResume, or a verified restart reconnect + * at the deployed generation. + */ +data class E2eTimeline( + val generation: Long, + val trigger: Long, + val compileDone: Long, + val deploySent: Long, + val reloadLive: Long, + /** + * Per-tool step durations as the daemon reported them; null when no step reported one (a + * pre-timing daemon, or a route that ran no tools). + * + * Deliberately not part of [format]: the log line is a harness contract kept narrow, so + * step timings travel only through the structured metrics sinks. + */ + val steps: StepTimings? = null, + /** + * The host-side spans that partition the build half of the loop. Null when unmeasured. + * Distinct from [steps], which nest inside them - see [accountedMillis]. + */ + val spans: HostSpans? = null, + /** How much work this build did, for reading a slow row. Null when unreported. */ + val counts: BuildCounts? = null, + /** + * Filesystem the daemon's scratch tree lives on (`ext4`, `f2fs`, `fuse`, ...); null when + * the daemon did not report one. + * + * Session-constant, but carried per row because it predicts every duration here: the + * daemon's per-file work costs about 52x more on FUSE-backed emulated storage + * (measured under ADFA-4128). + */ + val scratchFsType: String? = null, +) { + /** + * One build's per-tool durations; a null field means that step did not run or report. + * + * These nest inside [HostSpans] - kotlin/java/preSnap/postSnap/javaAbiSnap inside + * [HostSpans.compileRpcMillis], strip/d8 inside [HostSpans.dexRpcMillis], the aapt2 pair + * inside [HostSpans.relinkRpcMillis] - so never add them to an accounting sum. That is + * what [accountedMillis] is for. + * + * @property kotlinMillis the Kotlin incremental compile. + * @property javaMillis the Java compile, which recompiles every `.java` source today. + * @property stripMillis stripping the class tree down to what d8 is fed. + * @property d8Millis dexing that stripped tree. + * @property aapt2CompileMillis compiling the changed resources; absent on a code-only route. + * @property aapt2LinkMillis relinking the resource table; absent on a code-only route. + * @property preSnapMillis output-tree walk before the compile. + * @property postSnapMillis output-tree walk after it, which yields the changed-class set. + * @property javaAbiSnapMillis re-parse of every `.java` source's declarations. + */ + data class StepTimings( + val kotlinMillis: Long? = null, + val javaMillis: Long? = null, + val stripMillis: Long? = null, + val d8Millis: Long? = null, + val aapt2CompileMillis: Long? = null, + val aapt2LinkMillis: Long? = null, + val preSnapMillis: Long? = null, + val postSnapMillis: Long? = null, + val javaAbiSnapMillis: Long? = null, + ) { + /** The two output-tree walks as one number; null when neither was reported. */ + val walkMillis: Long? + get() = + if (preSnapMillis == null && postSnapMillis == null) { + null + } else { + (preSnapMillis ?: 0) + (postSnapMillis ?: 0) + } + + /** + * True when no step reported a duration. + * + * @return true when every field is null, which a sink reads as "the daemon reported no + * step timings" rather than as a build that took no time. + */ + fun isEmpty(): Boolean = + kotlinMillis == null && javaMillis == null && stripMillis == null && + d8Millis == null && aapt2CompileMillis == null && aapt2LinkMillis == null && + preSnapMillis == null && postSnapMillis == null && javaAbiSnapMillis == null + } + + /** + * The host-observed spans of one build, measured around each step the executor drives. + * + * They are mutually exclusive and all sit inside `[trigger, deploySent]`, so with + * [reloadMillis] they account for [totalMillis] - which is what makes [unaccountedMillis] + * meaningful. + * + * @property queueMillis t0 until this build actually started - queueing behind an in-flight + * build plus the hop onto the session's single thread, measured because it can be the + * largest phase of a warm save and would otherwise read as an unexplained residual. + * @property scanMillis enumerating the project's sources. + * @property compileRpcMillis the whole `compile` round trip, daemon time included. + * @property policyMillis the deploy policy's pass over every changed class header. + * @property dexRpcMillis the whole `dex` round trip. + * @property relinkRpcMillis the whole `relink` round trip; absent on code-only routes. + */ + data class HostSpans( + val queueMillis: Long? = null, + val scanMillis: Long? = null, + val compileRpcMillis: Long? = null, + val policyMillis: Long? = null, + val dexRpcMillis: Long? = null, + val relinkRpcMillis: Long? = null, + ) { + /** Sum of the measured spans; an unmeasured one contributes nothing. */ + val totalMillis: Long + get() = + (queueMillis ?: 0) + (scanMillis ?: 0) + (compileRpcMillis ?: 0) + (policyMillis ?: 0) + + (dexRpcMillis ?: 0) + (relinkRpcMillis ?: 0) + + /** + * True when no span was measured. + * + * @return true when every field is null, which is what makes [unaccountedMillis] report + * zero rather than the whole loop. + */ + fun isEmpty(): Boolean = + queueMillis == null && scanMillis == null && compileRpcMillis == null && + policyMillis == null && dexRpcMillis == null && relinkRpcMillis == null + } + + /** + * How much work the build did. Counters only - no paths, no names, no content. + * + * @property allSources sources handed to the compiler. + * @property kotlinDeclaredChanged Kotlin sources the daemon declared changed to the Kotlin + * engine. NOT the number recompiled - the engine widens the set itself, so a build can + * recompile files this count does not include. Named for what it measures because reading + * it as "recompiled" has already sent one investigation the wrong way. + * @property javaSources `.java` sources, all recompiled every build today. + * @property changedClasses `.class` files this build emitted or rewrote. + * @property classFiles classes the dex step stripped and dexed - the whole tree. + * @property classBytes their total size. + * @property compileOrdinal 1-based compile index within the daemon session, where `1` is the + * cold build that seeds the incremental caches and must not be read as a warm edit. + */ + data class BuildCounts( + val allSources: Int? = null, + val kotlinDeclaredChanged: Int? = null, + val javaSources: Int? = null, + val changedClasses: Int? = null, + val classFiles: Int? = null, + val classBytes: Long? = null, + val compileOrdinal: Long? = null, + ) { + /** + * True when the build reported no counters. + * + * @return true when every field is null; a build that genuinely compiled nothing still + * reports zeros, so the two cases stay distinguishable. + */ + fun isEmpty(): Boolean = + allSources == null && kotlinDeclaredChanged == null && javaSources == null && + changedClasses == null && classFiles == null && classBytes == null && + compileOrdinal == null + } + + /** Trigger -> compiled+dexed (or relinked, for a no-compile route). */ + val compileMillis: Long get() = compileDone - trigger + + /** Compiled -> about to deploy: relink + asset packaging on a mixed route, ~0 on code-only. */ + val stageMillis: Long get() = deploySent - compileDone + + /** Deploy handed off -> confirmed live: binder round-trip + the proxy app's reload. */ + val reloadMillis: Long get() = reloadLive - deploySent + + /** The whole loop the user feels: file change -> new code on screen. */ + val totalMillis: Long get() = reloadLive - trigger + + /** + * How much of [totalMillis] a named span actually measured: the host spans, which + * partition `[trigger, deploySent]`, plus [reloadMillis] for the rest. + * + * [steps] are excluded on purpose - they nest inside the host spans, so counting them + * would double-count. + */ + val accountedMillis: Long get() = (spans?.totalMillis ?: 0) + reloadMillis + + /** + * The part of the loop no span measured - the field this event exists for. + * + * Reporting it keeps unmeasured work visible: the per-tool timings alone cover only about half a + * warm edit `[measured on a56]`, and what is left outside every span is the asset packaging + * before the compile plus the tail between the last tool and the deploy. A near-zero residual is + * the healthy state; one that grows means a step is running that nothing times. + */ + val unaccountedMillis: Long get() = if (spans == null) 0 else totalMillis - accountedMillis + + /** + * The single structured line CoGo logs per generation. Grep-stable: the harness keys + * on the literal `[LOG_TAG]` prefix, and every field is `name=` so a regex parse + * is unambiguous. + * + * The five stamps lead and never move, so a parser anchored on their order keeps matching. + * [BuildCounts.compileOrdinal] follows them because a duration is unreadable without it: the + * same edit costs seconds on a fresh daemon and hundreds of milliseconds once warm, so a line + * that does not say where on that curve it sits makes a warm-up look like variance. It is the + * only count here - the rest still travel through the structured sinks, since widening the + * line further would break the harness's parser. + * + * @return the log line, [LOG_TAG] first, then the five stamps, then `compileOrdinal` when a + * compile ran. A route that ran none - a resources-only relink, or a pre-timing daemon - + * omits the field rather than printing a zero that would read as a real ordinal. + */ + fun format(): String = + "$LOG_TAG gen=$generation trigger=$trigger compileDone=$compileDone " + + "deploySent=$deploySent reloadLive=$reloadLive" + + (counts?.compileOrdinal?.let { " compileOrdinal=$it" } ?: "") + + companion object { + /** + * The literal prefix of [format]'s line; the harness greps logcat for it. The reader is + * the benchmark harness's own Python parser, not this module - nothing here parses the + * line back, so the shape is frozen by that external contract alone. + */ + const val LOG_TAG = "quickbuild-e2e:" + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/QuickBuildMetricsSink.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/QuickBuildMetricsSink.kt new file mode 100644 index 0000000000..7b2e115dd4 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/QuickBuildMetricsSink.kt @@ -0,0 +1,116 @@ +package org.appdevforall.cotg.quickbuild.domain.telemetry + +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome + +/** + * Port for per-build run statistics: change-set size, route, run time, invalidations, and + * proxy-app-rebuild cost. The app layer wires an analytics-backed implementation; the domain + * knows only this interface. + * + * Implementations must be cheap and must not throw - metrics can never affect a build. + * Callers guard every call, so a misbehaving sink degrades to a logged warning. + */ +interface QuickBuildMetricsSink { + /** + * Records the start of a new live session. + * + * Build ids restart at 1 per session, so a sink that exports them must mint a fresh + * session id here to keep (session, build) unique. + */ + fun onSessionStarted() + + /** + * Records a quick build leaving the queue. + * + * @param buildId orchestrator-unique id, restarting at 1 each session; pair it with the + * session id minted in [onSessionStarted] to key a row. + * @param route the path chosen for this change-set, never [BuildRoute.FullGradleBuild] - + * that one leaves the live reload path before a build starts. + * @param changes the coalesced set the route was computed from. + */ + fun onBuildStarted( + buildId: Long, + route: BuildRoute, + changes: ChangedFiles, + ) + + /** + * Records the build's outcome, successful or not. Pairs 1:1 with [onBuildStarted]. + * + * @param buildId the id the matching [onBuildStarted] carried. + * @param outcome how the build ended; only [BuildOutcome.Success] moved the proxy app to a + * new generation. + */ + fun onBuildFinished( + buildId: Long, + outcome: BuildOutcome, + ) + + /** + * Records a change set that forced the session off the live reload path. + * + * @param reason what the live reload path could not absorb; every value costs a full Gradle + * build. + */ + fun onInvalidation(reason: InvalidationReason) + + /** + * Records one completed save->live loop, both the end-to-end time and the per-stage split. + * Fired once per successful deploy, keyed by generation id. Defaulted so existing sinks + * stay source-compatible. + * + * @param timeline the four monotonic stamps bounding the loop plus any reported step + * timings; read its deltas, never its absolute stamps. + */ + fun onReloadTimeline(timeline: E2eTimeline) {} + + /** + * Records a finished full proxy app rebuild - the cost of every fallback route. + * + * @param isSuccess whether the rebuild produced an installable proxy app; a declined or + * unconfirmed install still counts as a failure here. + * @param durationMillis wall-clock cost of the Gradle build, in milliseconds. The + * relaunch is deliberately outside this span, which existing consumers already parse + * as the build cost. + * @param relaunchOk true only when the reinstalled app was relaunched and its runtime + * reconnected; false on every failure, including a rebuild that never got as far as + * a relaunch. + * @param toRunningMillis rebuild start to the relaunched runtime's reconnect, in + * milliseconds - the same "app loaded and starting to run" endpoint the deploy paths + * measure to. Null whenever [relaunchOk] is false, never a measured zero. + */ + fun onProxyAppRebuild( + isSuccess: Boolean, + durationMillis: Long, + relaunchOk: Boolean, + toRunningMillis: Long?, + ) + + /** Sink that records nothing. */ + object Noop : QuickBuildMetricsSink { + override fun onSessionStarted() = Unit + + override fun onBuildStarted( + buildId: Long, + route: BuildRoute, + changes: ChangedFiles, + ) = Unit + + override fun onBuildFinished( + buildId: Long, + outcome: BuildOutcome, + ) = Unit + + override fun onInvalidation(reason: InvalidationReason) = Unit + + override fun onProxyAppRebuild( + isSuccess: Boolean, + durationMillis: Long, + relaunchOk: Boolean, + toRunningMillis: Long?, + ) = Unit + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/README.md b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/README.md new file mode 100644 index 0000000000..1e4f1297c6 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/README.md @@ -0,0 +1,8 @@ +# `domain/telemetry/` - the measurement vocabulary + +Pure-JVM types for measuring the live-reload loop: one timeline per edit and one sink to report it. No Android. `E2eTimeline` holds the four monotonic stamps that bound one generation's save-to-live loop, plus optional step timings, host spans, and build counts, and computes the per-stage and unaccounted deltas from them. `QuickBuildMetricsSink` is the port the app layer implements to record per-build statistics. + +| File | Purpose | +| --- | --- | +| [`E2eTimeline.kt`](E2eTimeline.kt) | One generation's four-stamp timeline plus `StepTimings`, `HostSpans`, `BuildCounts`; derives stage deltas and the grep-stable log `format`/`parse`. | +| [`QuickBuildMetricsSink.kt`](QuickBuildMetricsSink.kt) | Interface for recording session/build/invalidation/reload/rebuild stats; must be cheap and never throw. Includes a `Noop` implementation. | diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/BuildStatusJson.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/BuildStatusJson.kt new file mode 100644 index 0000000000..563760506e --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/BuildStatusJson.kt @@ -0,0 +1,95 @@ +package org.appdevforall.cotg.quickbuild.service.deploy + +import com.google.gson.JsonObject +import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic + +/** + * Builds the `statusJson` argument of `IQuickBuildTarget.onBuildStatus`. + * + * The builders below are the schema: each names its `kind` and the fields that go with + * it, and the runtime's `BuildStatus` is the only reader. Every value must be a STRING on the + * wire, because the runtime's MiniJson parser reads only strings. It ignores unknown kinds and + * fields, so the schema can grow without breaking installed proxy apps. + */ +object BuildStatusJson { + /** `kind` of the [buildFailed] message: a compile error the overlay shows. */ + const val KIND_BUILD_FAILED = "build_failed" + + /** `kind` of the [buildOk] message: clears whatever failure the overlay is showing. */ + const val KIND_BUILD_OK = "build_ok" + + /** `kind` of the [building] message: a build is in flight; the overlay says so. */ + const val KIND_BUILDING = "building" + + /** + * `kind` of the [reinstallPending] message: a rebuild finished but its reinstall is + * waiting on an install confirmation only CoGo can show. + */ + const val KIND_REINSTALL_PENDING = "reinstall_pending" + + /** + * Tells the proxy app a build has started while it keeps running [runningGeneration], + * so a slow build does not read as silence on screen. Cleared by the [buildFailed] or + * [buildOk] the same attempt eventually sends. + * + * @param runningGeneration the generation the app is still running, not the one being + * built; a caller with nothing truthful to say must not call this at all + * @return the `statusJson` argument for `onBuildStatus` + */ + fun building(runningGeneration: Long): String = + JsonObject() + .apply { + addProperty("kind", KIND_BUILDING) + addProperty("runningGeneration", runningGeneration.toString()) + }.toString() + + /** + * Reports a compile failure as the first line of the first error's message plus a count of + * the errors not shown - the overlay is a one-glance "your build failed and this app is + * stale" surface, not a build log. + * + * Deliberately position-free: jumping to an error is CoGo-side functionality, so + * file/line/column stay in Build Output rather than going to a runtime with no use for them. + * + * @param diagnostics every diagnostic the compile produced, in the compiler's order; + * errors are preferred over warnings when picking the one to show, and an empty list + * yields a kind-only message + * @return the `statusJson` argument for `onBuildStatus` + */ + fun buildFailed(diagnostics: List): String { + val errors = diagnostics.filter { it.severity == BuildDiagnostic.Severity.ERROR } + val shown = errors.firstOrNull() ?: diagnostics.firstOrNull() + val more = if (errors.isNotEmpty()) errors.size - 1 else 0 + return JsonObject() + .apply { + addProperty("kind", KIND_BUILD_FAILED) + shown + ?.message + ?.lineSequence() + ?.firstOrNull() + ?.let { addProperty("message", it) } + if (more > 0) { + addProperty("moreErrors", more.toString()) + } + }.toString() + } + + /** + * Reports a successful build, which clears a shown failure and renders nothing itself. + * + * @return the `statusJson` argument for `onBuildStatus` + */ + fun buildOk(): String = JsonObject().apply { addProperty("kind", KIND_BUILD_OK) }.toString() + + /** + * Tells the proxy app its pending update needs an install confirmation that can only be + * shown from CoGo, so the user staring at the stale app knows to switch back. + * + * Android defers the install-confirm while CoGo is backgrounded, and every other recovery + * signal lives in CoGo - the one app the user is not looking at. Kind-only on purpose: the + * copy is static and lives runtime-side with the other overlay text. + * + * @return the `statusJson` argument for `onBuildStatus` + */ + fun reinstallPending(): String = JsonObject().apply { addProperty("kind", KIND_REINSTALL_PENDING) }.toString() +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannel.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannel.kt new file mode 100644 index 0000000000..2434fa2a2e --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannel.kt @@ -0,0 +1,247 @@ +package org.appdevforall.cotg.quickbuild.service.deploy + +import android.os.ParcelFileDescriptor +import android.os.RemoteException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import org.appdevforall.cotg.quickbuild.service.telemetry.report +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Sends deploy payloads to the proxy app and awaits its verdict. + * + * An interface so the executor is unit-testable: the real channel touches + * [ParcelFileDescriptor] and binder, which only exist on device. + */ +interface DeploySender { + /** + * Delivers one payload to the connected proxy app and waits for it to reload or fail. + * + * All file params are optional per the AIDL contract; [metadataJson] follows the + * schema in quickbuild/README.md. + * + * @param generation the payload's generation; the runtime accepts only strictly newer + * ones, so this must come from the generation tracker and never be replayed + * @param dexFile the payload's classes, or null when the build changed no code + * @param arscFile the relinked resource APK, or null when resources did not move + * @param assetsZip the changed-assets archive, or null when no asset changed + * @param metadataJson entry activity and restart flag; see `PayloadDeployer.metadata` + * @return the proxy app's verdict; every bounded wait surfaces here rather than throwing + */ + suspend fun deploy( + generation: Long, + dexFile: File?, + arscFile: File?, + assetsZip: File?, + metadataJson: String, + ): DeployResult + + /** + * Tells the running proxy app a build failed or succeeded, when there is no payload + * to send. + * + * Fire-and-forget: no verdict, never throws. A disconnected proxy app, or one whose + * stub predates onBuildStatus, simply misses the message. + * + * @param statusJson built by [BuildStatusJson] + */ + fun notifyBuildStatus(statusJson: String) + + /** + * Waits until no proxy app is bound, so the restart path can confirm the runtime + * exited before relaunching. Relaunching a still-alive process would resume the old + * code. + * + * @param timeoutMillis upper bound on the wait, sized for a runtime exit rather than a + * process launch + * @return true when disconnected within [timeoutMillis] + */ + suspend fun awaitDisconnect(timeoutMillis: Long): Boolean + + /** + * Waits for a proxy app to reconnect, so the restart path can check which generation + * actually booted rather than assume the deployed one. + * + * @param timeoutMillis upper bound on the wait, sized for a cold app start on low-end + * hardware + * @return the generation the app reports running, or null on timeout + */ + suspend fun awaitReconnect(timeoutMillis: Long): Long? +} + +/** Terminal outcome of one deploy attempt. */ +sealed interface DeployResult { + /** + * The payload is live: the app loaded it and reported back. + * + * @property reloadMillis the app's own measure of the reload, from payload receipt to + * the recreated activity's onResume; the only span the host cannot time itself + */ + data class Reloaded( + val reloadMillis: Long, + ) : DeployResult + + /** + * The payload reached the app but crashed in render/lifecycle. + * + * @property stackSummary the runtime's one-line summary of the throwable, shown to the + * user as the deploy failure + */ + data class Crashed( + val stackSummary: String, + ) : DeployResult + + /** + * No proxy app was bound, so nothing was sent and nothing is stale. The caller may + * launch the app once and retry (see [PayloadDeployer]'s deploy-recovering path). + */ + data object NotConnected : DeployResult + + /** + * The proxy app disconnected while the deploy waited for its verdict. Fatal for a + * hot-swap deploy; for a restart deploy it is the expected process exit, which + * relaunch and binder catch-up then reconcile. + */ + data object Disconnected : DeployResult + + /** + * No verdict arrived in time, so whether the payload landed is unknown. + * + * @property timeoutMillis the bound that elapsed, echoed into the user-facing message + */ + data class TimedOut( + val timeoutMillis: Long, + ) : DeployResult + + /** + * The payload never reached the app: the binder call threw, or a payload file could + * not be opened as a read-only fd. + * + * @property message the binder or IO failure text, shown as the deploy failure + */ + data class Failed( + val message: String, + ) : DeployResult +} + +/** + * The on-device [DeploySender]: passes payload files as read-only fds over the oneway + * [com.itsaky.androidide.quickbuild.IQuickBuildTarget.onPayload] and awaits the matching + * report. + * + * Every wait is bounded, so a hung proxy app surfaces as [DeployResult.TimedOut] instead + * of a stuck build. + * + * @property connections the registry the bound proxy app and its reports arrive on + * @property timeoutMillis bound on one deploy round trip, from the oneway call to the + * matching report + */ +class DeployChannel( + private val connections: ProxyAppConnections, + private val timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, +) : DeploySender { + override suspend fun deploy( + generation: Long, + dexFile: File?, + arscFile: File?, + assetsZip: File?, + metadataJson: String, + ): DeployResult { + val connection = connections.target.value ?: return DeployResult.NotConnected + + return withTimeoutOrNull(timeoutMillis) { + coroutineScope { + // Subscribe BEFORE the oneway call: UNDISPATCHED runs until the flow + // collection suspends, so a fast report cannot slip past us. + val verdict = + async(start = CoroutineStart.UNDISPATCHED) { + connections.reports.first { report -> + when (report) { + is TargetReport.Reloaded -> report.generation == generation + is TargetReport.Crashed -> report.generation == generation + TargetReport.Disconnected -> true + } + } + } + + try { + // Hand the fds, not the bytes: the kernel dups them across the process + // boundary, so the running app loads straight from disk with no copy. The + // nested `use` closes our ends once the call returns; the proxy keeps its dups. + openReadOnly(dexFile).use { dexFd -> + openReadOnly(arscFile).use { arscFd -> + openReadOnly(assetsZip).use { assetsFd -> + connection.target.onPayload( + generation, + dexFd, + arscFd, + assetsFd, + metadataJson, + ) + } + } + } + } catch (e: RemoteException) { + verdict.cancel() + log.error("Deploy of generation {} failed at the binder", generation, e) + return@coroutineScope DeployResult.Failed("Binder call failed: ${e.message}") + } catch (e: java.io.IOException) { + verdict.cancel() + log.error("Deploy of generation {} could not open a payload fd", generation, e) + return@coroutineScope DeployResult.Failed("Cannot open payload: ${e.message}") + } + + when (val report = verdict.await()) { + is TargetReport.Reloaded -> DeployResult.Reloaded(report.reloadMillis) + is TargetReport.Crashed -> DeployResult.Crashed(report.stackSummary) + TargetReport.Disconnected -> DeployResult.Disconnected + } + } + } ?: DeployResult.TimedOut(timeoutMillis) + } + + override fun notifyBuildStatus(statusJson: String) { + val connection = connections.target.value ?: return + try { + connection.target.onBuildStatus(statusJson) + } catch (e: Exception) { + // Best-effort by contract (binder proxies can throw beyond RemoteException); + // the failure surface for builds is CoGo's own UI. + log.warn("Build-status message to the proxy app failed", e) + } + } + + override suspend fun awaitDisconnect(timeoutMillis: Long): Boolean = + // The awaited value is null by construction, so the block must yield its own + // non-null sentinel: returning `first { it == null }` would make a real + // disconnect indistinguishable from a timeout. + withTimeoutOrNull(timeoutMillis) { + connections.target.first { it == null } + true + } == true + + override suspend fun awaitReconnect(timeoutMillis: Long): Long? = + withTimeoutOrNull(timeoutMillis) { + connections.target.first { it != null }?.runningGeneration + } + + /** + * Opens one payload file as a read-only fd for the binder call. + * + * @param file the payload file, or null for an omitted payload slot + * @return the fd the caller must close, or null when [file] was null + */ + private fun openReadOnly(file: File?): ParcelFileDescriptor? = + file?.let { ParcelFileDescriptor.open(it, ParcelFileDescriptor.MODE_READ_ONLY) } + + companion object { + private val log = LoggerFactory.getLogger("QB-DeployChannel") + + /** Reload itself is ~40ms; the margin covers a cold proxy-app relaunch. */ + const val DEFAULT_TIMEOUT_MILLIS = 15_000L + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt new file mode 100644 index 0000000000..204701d2c3 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt @@ -0,0 +1,413 @@ +package org.appdevforall.cotg.quickbuild.service.deploy + +import com.google.gson.JsonObject +import org.appdevforall.cotg.quickbuild.data.AssetPackager +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.reload.DeployDecision +import org.appdevforall.cotg.quickbuild.domain.reload.GenerationTracker +import org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline +import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppLauncher +import org.appdevforall.cotg.quickbuild.service.telemetry.E2eTimelineRecorder +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Gets one build's artifacts into the running proxy app and reports whether they are live. + * + * Owns everything downstream of the deploy decision: hot swap versus process restart, the + * relaunch and reconnect checks, the retry when no app is connected, and the [DeployResult] to + * [BuildOutcome] mapping. Generations are allocated here, so a build that never deploys never + * burns one. Call only on the session dispatcher. + */ +internal class PayloadDeployer( + /** Deploy channel to the bound proxy app; every wait it exposes is already bounded. */ + private val deploy: DeploySender, + /** Generation allocator, pulled from only on a path that actually sends a payload. */ + private val generations: GenerationTracker, + /** The user app's entry activity FQN, echoed to the runtime in payload metadata. */ + private val entryActivity: String, + /** The installed proxy app's applicationId; restart relaunch target. */ + private val proxyAppPackage: String?, + /** + * Launcher proxy activity FQN from the transformed manifest, the restart relaunch + * target. Null when the MAIN/LAUNCHER filter sits on an `` that no + * proxied activity carries; the relaunch then uses the package's default launch + * intent. + */ + private val launcherActivity: String?, + /** Relaunches the app. Null makes both the restart path and the retry fail honestly. */ + private val launcher: ProxyAppLauncher?, + /** How long the runtime gets to exit after acking a restart deploy. */ + private val restartDisconnectTimeoutMillis: Long, + /** How long a relaunched app gets to boot, bind, and report its generation. */ + private val restartReconnectTimeoutMillis: Long, + /** Monotonic clock; must be the same one the timeline's earlier stamps came from. */ + private val clock: () -> Long, + /** Hands a completed timeline to the executor's log + analytics channels. */ + private val reportTimeline: (E2eTimeline) -> Unit, + /** + * Whether the build being deployed answers a Quick Build tap, read at the moment a launch + * would happen rather than captured up front, because a tap can promote a build already in + * flight. Starting an activity always takes the screen, so this separates a deploy that may + * bring the app forward from one that must not: a save is not permission to interrupt + * someone who is still typing. + */ + private val userInitiated: () -> Boolean = { true }, + /** + * Where a confirmed deploy's bytes are retained for the reconnect re-send + * (concurrency.md rules 3-4). Null retains nothing, which only costs the fallback: + * every below-deployed reconnect then repairs by forced rebuild. + */ + private val retention: RetainedPayloadStore? = null, +) { + /** + * Deploys one build's artifacts by the route [decision] chose, and reports the + * outcome. + * + * @param decision hot swap, process restart, or a refusal that needs a proxy app rebuild + * @param dexFile the payload's classes, or null when no code moved + * @param arscFile the relinked resource APK, or null when resources did not move + * @param assets the packaged changed assets, or null when no asset changed + * @param loopStartedAt t0 of the save-to-live loop, which the reported duration is measured + * from - the same span the timeline totals, so the two numbers cannot disagree + * @param recorder mutated in place; stamped with t2 here and t3 once the app confirms + * @return the outcome for the orchestrator; a generation is burned only on a path that + * actually sends a payload + */ + suspend fun deploy( + decision: DeployDecision, + dexFile: File?, + arscFile: File?, + assets: AssetPackager.PackagedAssets?, + loopStartedAt: Long, + recorder: E2eTimelineRecorder, + ): BuildOutcome = + when (decision) { + DeployDecision.Recreate -> { + deployPayload(generations.next(), dexFile, arscFile, assets, loopStartedAt, recorder) + } + + is DeployDecision.Restart -> { + deployRestart(decision, dexFile, arscFile, assets, loopStartedAt, recorder) + } + + is DeployDecision.RebuildProxyApp -> { + // Deploying anyway would hot-swap on a runtime that cannot restart, + // leaving a live service or provider on stale code. The session manager + // routes this refusal into the proxy app rebuild fallback. + BuildOutcome.RequiresProxyAppRebuild(InvalidationReason.OUTDATED_BASELINE, decision.detail) + } + } + + /** + * Hot-swap path: send the payload and let the running process recreate itself. + * + * @param generation the already-allocated generation this payload claims + * @param dexFile the payload's classes, or null when no code moved + * @param arscFile the relinked resource APK, or null when resources did not move + * @param assets the packaged changed assets, or null when no asset changed + * @param loopStartedAt t0 of the save-to-live loop, which the reported duration is measured + * from - the same span the timeline totals, so the two numbers cannot disagree + * @param recorder stamped with t2 before the send and t3 once the app reports back + * @return success only when the app confirmed the reload; every other result becomes a + * deploy failure + */ + private suspend fun deployPayload( + generation: Long, + dexFile: File?, + arscFile: File?, + assets: AssetPackager.PackagedAssets?, + loopStartedAt: Long, + recorder: E2eTimelineRecorder, + ): BuildOutcome { + recorder.markDeploySent(clock()) + val recovered = + deployRecovering(generation, dexFile, arscFile, assets?.zip, metadata(restart = false)) + return when (val result = recovered.result) { + is DeployResult.Reloaded -> { + // The app confirmed the payload, so these bytes are worth retaining for + // the reconnect re-send (concurrency.md rules 3-4). + retention?.retain(generation, dexFile, arscFile, assets?.zip, metadata(restart = false)) + // t3: reportReloaded came back from the recreated activity's onResume, + // so the new code is live. One clock read feeds both, or the reported + // duration would run past the timeline's own total for the same loop. + val liveAt = clock() + reportTimeline(recorder.completed(generation, liveAt)) + BuildOutcome.Success(generation, liveAt - loopStartedAt) + } + + else -> { + failureOf(result, generation, recovered.launched) + } + } + } + + /** + * Restart path: deploy with restart metadata, wait for the runtime to persist and + * exit, relaunch it, then check which generation came back. + * + * Only a reconnect at the deployed generation counts as success; anything lower means the + * payload was lost. A disconnect before the ack proceeds to relaunch, which settles it. + * + * The relaunch gets exactly two attempts, because a start can be silently swallowed by the + * task the killed process left behind and a second one then lands (see the retry's comment). + * Two, not a loop: a genuinely dead app must reach the user rather than become a retry storm. + * + * @param restart the decision, whose component class names the thing that forced a + * restart and appears in every message this path produces + * @param dexFile the payload's classes, or null when no code moved + * @param arscFile the relinked resource APK, or null when resources did not move + * @param assets the packaged changed assets, or null when no asset changed + * @param loopStartedAt t0 of the save-to-live loop, which the reported duration is measured + * from - the same span the timeline totals, so the two numbers cannot disagree + * @param recorder stamped with t2 before the send and t3 once the app reconnects + * @return success only on a reconnect at the deployed generation; a runtime that acked + * but never exited comes back as a proxy-app-rebuild requirement + */ + private suspend fun deployRestart( + restart: DeployDecision.Restart, + dexFile: File?, + arscFile: File?, + assets: AssetPackager.PackagedAssets?, + loopStartedAt: Long, + recorder: E2eTimelineRecorder, + ): BuildOutcome { + val generation = generations.next() + log.info( + "Restart deploy of generation {}: {} {} changed", + generation, + restart.kind, + restart.componentClass, + ) + recorder.markDeploySent(clock()) + val recovered = + deployRecovering(generation, dexFile, arscFile, assets?.zip, metadata(restart = true)) + when (val result = recovered.result) { + is DeployResult.Reloaded -> { + if (!deploy.awaitDisconnect(restartDisconnectTimeoutMillis)) { + // The runtime acked but kept running, so it predates restart support + // and hot-swapped instead, leaving a live service possibly stale. A + // proxy app rebuild reinstalls a current runtime. + return BuildOutcome.RequiresProxyAppRebuild( + InvalidationReason.OUTDATED_BASELINE, + "proxy app acknowledged a restart deploy but did not exit " + + "(runtime predates restart support)", + ) + } + } + + DeployResult.Disconnected -> { + Unit + } + + else -> { + return failureOf(result, generation, recovered.launched) + } + } + + val packageName = proxyAppPackage + val relauncher = launcher + // A null launcherActivity is expected for alias-launched apps; the launcher then + // resolves the package's launch intent, which points at the same alias the OS would. + if (packageName == null || relauncher?.launch(packageName, launcherActivity) != true) { + // The process is gone so nothing runs stale code, but the loop stays broken + // until the user opens the app again. + return BuildOutcome.DeployFailure( + "Proxy app restarted for ${restart.componentClass} but could not be relaunched; " + + "open it manually to load the new code", + ) + } + var reconnectGeneration = deploy.awaitReconnect(restartReconnectTimeoutMillis) + if (reconnectGeneration == null) { + // A relaunch can be swallowed rather than refused: measured on an A56, an intent + // aimed at the task the killed process left behind was handed to that task's dead + // activity record and dropped, and the record was then removed with the task. The + // second intent finds no task and creates one, which is a live app at its first + // screen instead of a dead one - so try exactly once more before giving up. + log.info( + "Proxy app {} did not come back after the restart relaunch; launching it once more", + packageName, + ) + if (relauncher.launch(packageName, launcherActivity)) { + reconnectGeneration = deploy.awaitReconnect(restartReconnectTimeoutMillis) + } + } + return when { + reconnectGeneration == null -> { + // Says the app did not come back, not that it was relaunched: the launch call + // only reports that the start was issued, and Android blocks a background + // activity start silently, so a start that never took looks identical here. + // Two starts have been issued by now, so this is a genuinely dead app. + BuildOutcome.DeployFailure( + "Proxy app did not come back after restarting for ${restart.componentClass} " + + "(relaunched twice, $restartReconnectTimeoutMillis ms each); open it manually", + ) + } + + reconnectGeneration < generation -> { + // The payload did not survive the process death, so the fresh process + // booted an older generation. A proxy app rebuild reinstalls from + // current sources and brings every component back in step. + BuildOutcome.RequiresProxyAppRebuild( + InvalidationReason.OUTDATED_BASELINE, + "proxy app relaunched at generation $reconnectGeneration instead of " + + "$generation (restart payload did not persist)", + ) + } + + else -> { + // Retained with hot-swap metadata, not this deploy's restart flag: a + // reconnect catch-up must not ask the just-relaunched app to exit again. + retention?.retain(generation, dexFile, arscFile, assets?.zip, metadata(restart = false)) + // t3: the relaunched process reconnected at the deployed generation, so + // the restart swap is live. Slower than a hot swap by a full process + // launch. One clock read feeds both, as on the hot-swap path. + val liveAt = clock() + reportTimeline(recorder.completed(generation, liveAt)) + BuildOutcome.Success(generation, liveAt - loopStartedAt, restarted = true) + } + } + } + + /** + * Deploys once, and on [DeployResult.NotConnected] launches the app once and retries. + * + * A proxy app reinstall kills the process, and only the proxy app can re-establish the + * AIDL connection, so without this every later deploy fails until the user opens it by + * hand. Exactly one launch and one retry, so a hard-broken app never becomes a retry + * storm and the foreground is taken only when a deploy needs it. + * + * @param generation the already-allocated generation both attempts claim; the retry + * must not allocate a second one + * @param dexFile the payload's classes, or null when no code moved + * @param arscFile the relinked resource APK, or null when resources did not move + * @param assetsZip the changed-assets archive, or null when no asset changed + * @param metadataJson the metadata built for this route, reused verbatim on the retry + * @return the second attempt's result, or the first when no retry was possible + */ + private suspend fun deployRecovering( + generation: Long, + dexFile: File?, + arscFile: File?, + assetsZip: File?, + metadataJson: String, + ): RecoveredDeploy { + val first = deploy.deploy(generation, dexFile, arscFile, assetsZip, metadataJson) + if (first != DeployResult.NotConnected) return RecoveredDeploy(first, launched = false) + if (!userInitiated()) { + // A save must not open the app. Nobody asked for it, and starting an activity + // would pull the user out of the editor mid-edit. The failure is honest - there + // was nowhere to deploy - and the next tap launches the app and deploys then. + log.info("Deploy of generation {} found no connected proxy app; not launching it unasked", generation) + return RecoveredDeploy(first, launched = false) + } + val packageName = proxyAppPackage ?: return RecoveredDeploy(first, launched = false) + val relauncher = launcher ?: return RecoveredDeploy(first, launched = false) + log.info("Deploy of generation {} found no connected proxy app; relaunching it once", generation) + // A launch that never started is not evidence the app cannot stay up - nothing ran + // to fail. Only a started app that then fails to come back counts. + if (!relauncher.launch(packageName, launcherActivity)) return RecoveredDeploy(first, launched = false) + if (deploy.awaitReconnect(restartReconnectTimeoutMillis) == null) { + return RecoveredDeploy(first, launched = true) + } + return RecoveredDeploy( + deploy.deploy(generation, dexFile, arscFile, assetsZip, metadataJson), + launched = true, + ) + } + + /** + * A deploy attempt plus whether this call actually started the proxy app. + * + * [launched] separates "nobody has opened your app" from "your app will not stay up": it is + * true only when a launch really started the app and it still did not come back, which is + * the evidence a repeat escalates into the cannot-stay-up dialog. A save never launches, and + * a launch that failed to start never ran, so neither counts. + * + * @property result the verdict to report + * @property launched true only when the app was started and still failed to reconnect + * or deploy + */ + private data class RecoveredDeploy( + val result: DeployResult, + val launched: Boolean, + ) + + /** + * Builds the payload metadata the runtime reads. + * + * The field set is defined by the two ends and nowhere else: this builder writes it, and the + * runtime's `DeployMetadata` parses it. Adding a field here needs a matching read there - and + * a field the runtime does not read is bytes crossing a binder for nobody, which is why this + * is exactly the two keys it reads. + * + * @param restart true to ask the runtime to persist and exit rather than hot-swap + * @return the metadata JSON, every value a string per the runtime's MiniJson parser + */ + private fun metadata(restart: Boolean): String = + JsonObject() + .apply { + addProperty("entryActivity", entryActivity) + if (restart) addProperty("restart", "true") + }.toString() + + /** + * Turns a non-reloaded [DeployResult] into the outcome the user sees. + * + * @param result the deploy verdict; [DeployResult.Reloaded] is a caller error and maps + * to a failure rather than throwing, to keep the mapping total + * @param generation the generation the failed payload claimed, named in the message so + * the user can tell one failed deploy from another + * @param launchAttempted whether this deploy actually started the proxy app (see + * [RecoveredDeploy.launched]); deliberately without a default, since inferring it from + * [userInitiated] would flag every tap that never got as far as launching. + * @return the deploy failure, with remediation text wherever the user can act + */ + private fun failureOf( + result: DeployResult, + generation: Long, + launchAttempted: Boolean, + ): BuildOutcome = + when (result) { + is DeployResult.Crashed -> { + BuildOutcome.DeployFailure( + "Generation $generation crashed in the proxy app: ${result.stackSummary}", + ) + } + + DeployResult.NotConnected -> { + // proxyAppNotConnected means "we launched it and it still is not there", + // which is the evidence a repeat turns into the cannot-stay-up dialog. A + // save deliberately never launches, so flagging it here would accuse a + // perfectly healthy app of crashing just because nobody has opened it. + BuildOutcome.DeployFailure( + "Your app is not running. Tap Quick Build to start it with your changes.", + proxyAppNotConnected = launchAttempted, + ) + } + + DeployResult.Disconnected -> { + BuildOutcome.DeployFailure("Proxy app disconnected during deploy") + } + + is DeployResult.TimedOut -> { + BuildOutcome.DeployFailure( + "Proxy app did not confirm generation $generation within ${result.timeoutMillis} ms", + ) + } + + is DeployResult.Failed -> { + BuildOutcome.DeployFailure(result.message) + } + + is DeployResult.Reloaded -> { + // Callers handle Reloaded before mapping failures; keep the mapping total. + BuildOutcome.DeployFailure("unexpected Reloaded in failure mapping") + } + } + + private companion object { + private val log = LoggerFactory.getLogger("QB-PayloadDeployer") + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppConnections.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppConnections.kt new file mode 100644 index 0000000000..35b81e336e --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppConnections.kt @@ -0,0 +1,199 @@ +package org.appdevforall.cotg.quickbuild.service.deploy + +import android.os.IBinder +import com.itsaky.androidide.quickbuild.IQuickBuildTarget +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import org.slf4j.LoggerFactory + +/** + * Registry of the currently bound proxy app and its reports. + * + * [QuickBuildHostService] cannot be constructor-injected because the system creates it, so both + * sides meet here: the binder writes connections and reports in, the [DeployChannel] and session + * manager read them out as flows. A class with a process-wide [INSTANCE], rather than an object, + * so tests get isolated registries. + */ +class ProxyAppConnections { + /** + * The only uid inbound binder calls are accepted from, read from the installed proxy + * app's PackageManager entry at session start. Null means no live session, so every + * inbound call is rejected. + */ + @Volatile var expectedUid: Int? = null + private set + + /** Package name that goes with [expectedUid]; null when no session is live. */ + @Volatile var expectedPackage: String? = null + private set + + private val _target = MutableStateFlow(null) + + /** The currently bound proxy app, or null when none is connected. */ + val target: StateFlow = _target + + // Buffered so binder threads never suspend; a report burst beyond the buffer is + // dropped-oldest, which only ever loses superseded generations' reports. + private val _reports = + MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST, + ) + + /** Reload/crash/disconnect reports from the proxy app, in arrival order. */ + val reports: SharedFlow = _reports + + /** + * Keeps the connected proxy app out of the cached-app freezer; null until + * [installPriorityHold] runs, and on the JVM in tests that do not care. + */ + @Volatile private var priorityHold: ProxyAppPriorityHold? = null + + /** + * Supplies the hold this registry drives, replacing any previous one. + * + * Separate from construction because the only object with a Context is the + * Android-instantiated [QuickBuildHostService], which the system creates long after this + * process-wide registry exists. + * + * @param hold the hold to take on connect and drop on disconnect or session end + */ + fun installPriorityHold(hold: ProxyAppPriorityHold) { + priorityHold = hold + } + + /** Drops the hold and forgets it, for when the object that supplied it is going away. */ + fun uninstallPriorityHold() { + priorityHold?.release() + priorityHold = null + } + + /** + * Opens the registry to one proxy app, the only caller accepted until [endSession]. + * + * @param packageName the installed proxy app's package, for logging and reporting only + * @param uid the uid PackageManager reports for that package; this is the whole trust + * boundary of the exported host service, so it must come from PackageManager and + * never from anything the caller sent + */ + fun beginSession( + packageName: String, + uid: Int, + ) { + log.info("Quick-build session accepts proxy app {} (uid {})", packageName, uid) + expectedPackage = packageName + expectedUid = uid + } + + /** Closes the registry: no proxy app is accepted again until the next [beginSession]. */ + fun endSession() { + expectedPackage = null + expectedUid = null + _target.value = null + // Nothing can deploy to the app now, so stop exempting it from the freezer: a hold + // kept past session end would cost the user battery on an app they are just running. + priorityHold?.release() + } + + /** + * Publishes a proxy app that just bound, replacing any previous one. + * + * @param connection the bound target and the generation it reported at connect time; + * that generation goes stale as soon as a hot swap lands without a rebind + */ + fun onConnected(connection: ConnectedTarget) { + _target.value = connection + // Hold the PackageManager-sourced package, never connection.packageName: that one is + // the caller's own report, and this call starts and keeps alive a process by name. + // Null means no live session, in which case there is nothing to protect. + expectedPackage?.let { priorityHold?.hold(it) } + } + + /** + * Publishes the loss of the bound proxy app, waking anyone awaiting a verdict. + * + * @param died the binder whose death prompted this, or null to drop unconditionally + * (session end, or the app's own goodbye). A death notification from a superseded proxy + * app process arrives after its replacement has already registered, so a non-null value + * that is not the registered binder is ignored: clearing there would deploy the next + * save into NotConnected against a healthy bound app, and drop the freezer hold with it. + */ + fun onDisconnected(died: IBinder? = null) { + val current = _target.value + if (died != null && current != null && current.target.asBinder() !== died) { + log.info("Ignoring death of a superseded proxy app binder; a live one is registered") + return + } + _target.value = null + _reports.tryEmit(TargetReport.Disconnected) + // No process left to protect. A relaunch reconnects and [onConnected] re-takes it. + priorityHold?.release() + } + + /** + * Publishes one report from the proxy app to [reports]. + * + * @param report the inbound report; dropped silently if the buffer is full, which only + * ever discards a superseded generation's report + */ + fun report(report: TargetReport) { + _reports.tryEmit(report) + } + + companion object { + private val log = LoggerFactory.getLogger("QB-ProxyConnections") + + /** Process-wide registry the Android service and the Koin graph both use. */ + val INSTANCE = ProxyAppConnections() + } +} + +/** + * A bound proxy app and the generation it reported running at connect time. + * + * @property target the AIDL callback every deploy travels over + * @property packageName the proxy app's own report of its package, for logging only - the + * uid gate, not this, is what authorizes the caller + * @property runningGeneration fresh only at connect time; it goes stale as soon as a hot + * swap lands without a rebind, so prefer the session's own deploy tally when there is one + */ +data class ConnectedTarget( + val target: IQuickBuildTarget, + val packageName: String, + val runningGeneration: Long, +) + +/** Feedback from the proxy app after a deploy (or its death). */ +sealed interface TargetReport { + /** + * A payload went live. + * + * @property generation the payload's generation, which the waiter matches against its + * own so a superseded build's report is never mistaken for the current one + * @property reloadMillis the app's own measure of the reload, ending at the recreated + * activity's onResume + */ + data class Reloaded( + val generation: Long, + val reloadMillis: Long, + ) : TargetReport + + /** + * A payload reached the app but threw in render or lifecycle. + * + * @property generation the payload's generation, matched the same way as [Reloaded] + * @property stackSummary one-line summary of the throwable, surfaced to the user + */ + data class Crashed( + val generation: Long, + val stackSummary: String, + ) : TargetReport + + /** + * The bound app went away. Carries no generation because it answers every waiter, not + * just the one whose payload was in flight. + */ + data object Disconnected : TargetReport +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppPriorityHold.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppPriorityHold.kt new file mode 100644 index 0000000000..b0d8b095ba --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppPriorityHold.kt @@ -0,0 +1,143 @@ +package org.appdevforall.cotg.quickbuild.service.deploy + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import org.slf4j.LoggerFactory + +/** + * Keeps the connected proxy app answerable for as long as a session can deploy to it. + * + * The proxy app is in the background for the whole edit loop - the developer is typing in + * CoGo - so Android caches it and its freezer SIGSTOPs it after about a minute. A frozen + * process runs no binder threads, so it never answers the reload handshake and every save + * from then on fails the deploy timeout. The app's own outward binding to CoGo cannot + * prevent this: a binding raises the priority of the process hosting the *service*, and the + * `IQuickBuildTarget` callback CoGo holds is a plain binder object, which confers nothing. + * + * An interface so the lifecycle in [ProxyAppConnections] is unit-testable without binder. + */ +interface ProxyAppPriorityHold { + /** + * Holds [packageName] out of the cached-app freezer, replacing any previous hold. + * + * Idempotent per package, so it is safe to call on every reconnect. + * + * @param packageName the installed proxy app to protect. Must be the package + * PackageManager reported at session start, never one a caller sent over the binder - + * this starts and keeps alive a process by name. + */ + fun hold(packageName: String) + + /** Drops the hold, letting the app be cached and frozen again. Idempotent. */ + fun release() +} + +/** + * The on-device [ProxyAppPriorityHold]: binds CoGo into the proxy app's keep-alive service, making + * that process bound-service rather than cached so Android does not freeze it (measured on a + * Galaxy A56, `freezer_cutoff_adj` 850; unbound, it is frozen ~66 s after losing the foreground). + * + * Taken only once the app has connected, so it never starts an app the user did not run, and + * dropped on disconnect and at session end. Deliberately plain [Context.BIND_AUTO_CREATE] - + * `BIND_ABOVE_CLIENT` or `BIND_IMPORTANT` would rank a background app over the IDE being typed in. + * + * @property bind binds CoGo into the named package's keep-alive service, returning what + * `bindService` returned; failures must surface as false rather than throw. + * @property unbind tears the current binding down; must tolerate being called after a failed + * [bind], which is required to clear the framework's `ServiceConnection` registration. + */ +class BoundServicePriorityHold internal constructor( + private val bind: (String) -> Boolean, + private val unbind: () -> Unit, +) : ProxyAppPriorityHold { + /** The package currently held, or null when nothing is. Guarded by `this`. */ + private var heldPackage: String? = null + + @Synchronized + override fun hold(packageName: String) { + if (heldPackage == packageName) return + // A hold on a different package can only mean a new session's app; drop the old one + // rather than stacking bindings. + if (heldPackage != null) release() + + if (bind(packageName)) { + heldPackage = packageName + log.info("Holding proxy app {} out of the cached-app freezer", packageName) + return + } + // bindService returning false still leaves the ServiceConnection registered, so the + // unbind is required here or the framework reports a leaked connection and the next + // hold binds a second time. + unbind() + log.warn( + "Could not bind the keep-alive service of {}; it will be frozen ~1 min after it " + + "leaves the foreground and saves will then time out", + packageName, + ) + } + + @Synchronized + override fun release() { + val held = heldPackage ?: return + heldPackage = null + unbind() + log.info("Released the freezer hold on proxy app {}", held) + } + + companion object { + private val log = LoggerFactory.getLogger("QB-PriorityHold") + + /** + * The proxy app's keep-alive component, declared by the runtime AAR that every proxy + * app bakes in. Must match `QuickBuildKeepAliveService` and the Gradle plugin's + * `UNPROXIABLE_BY_NAME` entry that keeps the manifest transform from renaming it. + */ + const val KEEP_ALIVE_SERVICE = "com.itsaky.androidide.quickbuild.runtime.QuickBuildKeepAliveService" + + /** + * Builds a hold that binds from [context]. + * + * @param context any CoGo context; only its application context is retained. + * @return a hold whose bind/unbind go to the real framework. + */ + fun forContext(context: Context): BoundServicePriorityHold { + val appContext = context.applicationContext + // One connection object for the lifetime of this hold: unbindService is keyed on + // it, so a per-bind connection would make release() unable to match the bind. + val connection = + object : ServiceConnection { + override fun onServiceConnected( + name: ComponentName?, + service: IBinder?, + ) { + log.debug("Keep-alive connected: {}", name?.flattenToShortString()) + } + + override fun onServiceDisconnected(name: ComponentName?) { + // The app's process died. The binding stays valid and the framework + // reconnects if it comes back; the session's own disconnect handling is + // what decides whether the hold is still wanted. + log.debug("Keep-alive disconnected: {}", name?.flattenToShortString()) + } + } + return BoundServicePriorityHold( + bind = { packageName -> + val intent = Intent().setComponent(ComponentName(packageName, KEEP_ALIVE_SERVICE)) + runCatching { appContext.bindService(intent, connection, Context.BIND_AUTO_CREATE) } + .onFailure { log.warn("bindService to {} threw", packageName, it) } + .getOrDefault(false) + }, + unbind = { + // Not-registered is the normal outcome after a failed bind, and is not worth + // a warning. + runCatching { appContext.unbindService(connection) } + .onFailure { log.debug("unbindService: {}", it.toString()) } + Unit + }, + ) + } + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/QuickBuildHostService.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/QuickBuildHostService.kt new file mode 100644 index 0000000000..5998995ca1 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/QuickBuildHostService.kt @@ -0,0 +1,145 @@ +package org.appdevforall.cotg.quickbuild.service.deploy + +import android.app.Service +import android.content.Intent +import android.os.Binder +import android.os.IBinder +import com.itsaky.androidide.quickbuild.IQuickBuildHost +import com.itsaky.androidide.quickbuild.IQuickBuildTarget +import org.slf4j.LoggerFactory + +/** + * CoGo side of the deploy channel: the proxy app binds on launch and registers its + * [IQuickBuildTarget], and deploys travel back over that callback as fds. + * + * The service is exported, so the uid gate is the whole trust boundary: every inbound call must + * come from the uid PackageManager reported for the installed proxy app at session start, and + * anything else - including any call with no live session - is rejected with a SecurityException. + */ +class QuickBuildHostService : Service() { + private val binder = HostBinder(ProxyAppConnections.INSTANCE) + + /** + * Gives the registry the freezer hold it drives. This service is the first object in the + * deploy path with a Context, and its lifetime already spans the binding it protects: the + * proxy app's own bind is what creates it, so it outlives every connect it will see. + */ + override fun onCreate() { + super.onCreate() + ProxyAppConnections.INSTANCE.installPriorityHold(BoundServicePriorityHold.forContext(this)) + } + + /** Drops the hold, so no binding outlives the service that owns its context. */ + override fun onDestroy() { + ProxyAppConnections.INSTANCE.uninstallPriorityHold() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? { + if (intent?.action != ACTION_QUICK_BUILD) { + log.debug("Rejecting bind request: action={}", intent?.action) + return null + } + return binder + } + + /** + * The AIDL surface the proxy app calls, publishing every accepted call to [connections]. + * + * @property connections supplies the expected uid every inbound call is checked against, + * and receives whatever survives that check + */ + internal class HostBinder( + private val connections: ProxyAppConnections, + ) : IQuickBuildHost.Stub() { + /** The binder currently watched and the recipient watching it, so a reconnect can unlink. */ + private var deathWatch: Pair? = null + + override fun connect( + target: IQuickBuildTarget?, + packageName: String?, + runningGeneration: Long, + ) { + enforceCaller("connect") + if (target == null || packageName == null) { + throw SecurityException("connect() with null target or packageName") + } + + watchForDeath(target.asBinder()) + + log.info("Proxy app {} connected at generation {}", packageName, runningGeneration) + connections.onConnected(ConnectedTarget(target, packageName, runningGeneration)) + } + + override fun reportReloaded( + generation: Long, + reloadMillis: Long, + ) { + enforceCaller("reportReloaded") + connections.report(TargetReport.Reloaded(generation, reloadMillis)) + } + + override fun reportCrash( + generation: Long, + stackSummary: String?, + ) { + enforceCaller("reportCrash") + connections.report(TargetReport.Crashed(generation, stackSummary ?: "unknown crash")) + } + + /** + * Points the death watch at [binder], dropping the watch a superseded process left. + * + * Clearing the registration on death is what makes a deploy into a dead proxy app fail + * fast as NotConnected instead of timing out on its binder. The unlink matters because a + * recipient would otherwise accumulate one per reconnect, and the binder is passed on so + * a late death from a superseded process cannot wipe the live registration. + * + * @param binder the connecting target's binder; null only for a local (non-binder) target, + * which cannot die out from under us and so needs no watch + */ + @Synchronized + private fun watchForDeath(binder: IBinder?) { + if (binder == null) return + deathWatch?.let { (previous, recipient) -> + runCatching { previous.unlinkToDeath(recipient, 0) } + } + val recipient = IBinder.DeathRecipient { connections.onDisconnected(binder) } + deathWatch = binder to recipient + runCatching { binder.linkToDeath(recipient, 0) } + } + + override fun disconnect(packageName: String?) { + enforceCaller("disconnect") + log.info("Proxy app {} disconnected", packageName) + connections.onDisconnected() + } + + /** + * Throws unless the caller is the proxy app the live session accepts. + * + * @param op the AIDL method name, for the rejection log and message only + * @throws SecurityException when no session is live, or the calling uid is not the + * one PackageManager reported for the installed proxy app + */ + private fun enforceCaller(op: String) { + val expected = connections.expectedUid + val calling = Binder.getCallingUid() + if (expected == null || calling != expected) { + val error = + SecurityException( + "Rejected $op from uid $calling (expected ${expected ?: "no live session"})", + ) + log.warn("Quick-build host rejected a call", error) + throw error + } + } + } + + companion object { + private val log = LoggerFactory.getLogger("QB-HostService") + + /** Matches the manifest intent-filter and the runtime's bind intent. */ + const val ACTION_QUICK_BUILD = "com.itsaky.androidide.QUICK_BUILD_ACTION" + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/README.md b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/README.md new file mode 100644 index 0000000000..88f18625c2 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/README.md @@ -0,0 +1,11 @@ +# `service/deploy/` - the AIDL channel to the running proxy app + +This folder holds the deploy side of the service layer: the exported host service the proxy app binds to, the uid-gated connection registry, the channel that sends build payloads (dex/arsc/assets as fds) over AIDL and awaits a verdict, and the deployer that routes each build to hot swap or process restart. `QuickBuildHostService` is Android-instantiated and meets the session pipeline through the process-wide `ProxyAppConnections.INSTANCE`; the rest depends down on `data/` and `domain/`. + +| File | Purpose | +| --- | --- | +| [`QuickBuildHostService.kt`](QuickBuildHostService.kt) | Exported CoGo-side `Service`; the proxy app binds and registers its callback, and every inbound call is uid-gated against the session's expected proxy app. | +| [`ProxyAppConnections.kt`](ProxyAppConnections.kt) | Registry shared between the binder and the session pipeline: the bound target, the accepted uid/package, and the report flow. | +| [`DeployChannel.kt`](DeployChannel.kt) | The on-device `DeploySender`: passes payload files as read-only fds over the oneway `onPayload`, awaits the matching report, and bounds every wait. | +| [`PayloadDeployer.kt`](PayloadDeployer.kt) | Routes a build's artifacts to hot swap vs process restart, handles relaunch/reconnect and the no-app retry, allocates generations, and maps each `DeployResult` to a `BuildOutcome`. | +| [`BuildStatusJson.kt`](BuildStatusJson.kt) | Builds the string-valued `statusJson` for `onBuildStatus` (building, build_ok, build_failed, reinstall_pending) the proxy app's overlay reads. | diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/RetainedPayloadStore.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/RetainedPayloadStore.kt new file mode 100644 index 0000000000..8a85ddaf4e --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/RetainedPayloadStore.kt @@ -0,0 +1,169 @@ +package org.appdevforall.cotg.quickbuild.service.deploy + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Keeps the bytes of the last successfully deployed payload, so a proxy app reconnecting + * below the deployed generation can be answered by re-sending them at their original + * generation (concurrency.md rules 3-4) instead of by a forced blind rebuild. + * + * Payloads are cumulative over their baseline, so the last-deployed set alone brings a + * same-baseline app fully current. The bytes are copied because the executor's own artifacts + * (the daemon's dex, the staged assets zip) are overwritten by the next build. + * + * Everything here is best-effort by contract: a failed [retain] or an unreadable [load] only + * costs the caller its fallback - the forced catch-up build - never a build result. Call only + * on the session dispatcher. + * + * @property dir the retention directory, replaced wholesale by every [retain] + */ +internal class RetainedPayloadStore( + private val dir: File, +) { + /** + * One retained payload, exactly as it was deployed. + * + * @property generation the generation the deploy claimed; a re-send replays it unchanged, + * and the runtime's strictly-newer gate accepts it because the reconnected app runs + * something older + * @property metadataJson metadata for the re-send; always the hot-swap variant, since a + * reconnect catch-up must not ask the just-relaunched app to persist and exit again + * @property dexFile the retained classes, or null when the deploy carried none + * @property arscFile the retained resource APK, or null when the deploy carried none + * @property assetsZip the retained changed-assets zip, or null when the deploy carried none + */ + data class RetainedPayload( + val generation: Long, + val metadataJson: String, + val dexFile: File?, + val arscFile: File?, + val assetsZip: File?, + ) + + /** + * Replaces the retained set with this deploy's artifacts. Call only after the proxy app + * confirmed the payload, so what is retained is always something known to have run. + * + * The swap goes through a staging dir: a crash at any point leaves either the previous + * set, or nothing - never a half-written mix that [load] could hand to a re-send. + * + * @param generation the generation the confirmed deploy claimed + * @param dexFile the deployed classes, or null when the build moved no code + * @param arscFile the deployed resource APK, or null when resources did not move + * @param assetsZip the deployed changed-assets zip, or null when no asset changed + * @param metadataJson the metadata a re-send should use (the hot-swap variant) + */ + fun retain( + generation: Long, + dexFile: File?, + arscFile: File?, + assetsZip: File?, + metadataJson: String, + ) { + val staging = stagingDir() + try { + staging.deleteRecursively() + check(staging.mkdirs()) { "could not create ${staging.absolutePath}" } + dexFile?.copyTo(File(staging, DEX_NAME)) + arscFile?.copyTo(File(staging, ARSC_NAME)) + assetsZip?.copyTo(File(staging, ASSETS_NAME)) + File(staging, META_NAME).writeText( + JsonObject() + .apply { + addProperty("generation", generation) + addProperty("metadata", metadataJson) + addProperty("hasDex", dexFile != null) + addProperty("hasArsc", arscFile != null) + addProperty("hasAssets", assetsZip != null) + }.toString(), + ) + dir.deleteRecursively() + check(staging.renameTo(dir)) { "could not move staging into ${dir.absolutePath}" } + } catch (e: Exception) { + staging.deleteRecursively() + log.warn( + "Could not retain the deployed payload of generation {}; a reconnect catch-up will rebuild instead", + generation, + e, + ) + } + } + + /** + * Reads the retained set back for a re-send. + * + * @return the retained payload, or null when nothing is retained or the set is unreadable + * (missing part, corrupt metadata) - either way the caller falls back to rebuilding + */ + fun load(): RetainedPayload? { + val meta = File(dir, META_NAME) + if (!meta.isFile) return null + return try { + val json = JsonParser.parseString(meta.readText()).asJsonObject + RetainedPayload( + generation = json.get("generation").asLong, + metadataJson = json.get("metadata").asString, + dexFile = part(json, "hasDex", DEX_NAME), + arscFile = part(json, "hasArsc", ARSC_NAME), + assetsZip = part(json, "hasAssets", ASSETS_NAME), + ) + } catch (e: Exception) { + log.warn("Retained payload under {} is unreadable; a reconnect catch-up will rebuild instead", dir, e) + null + } + } + + /** + * Drops the retained set. Call whenever the baseline changes: the old baseline's bytes + * must never be replayed onto a new one. + */ + fun clear() { + dir.deleteRecursively() + stagingDir().deleteRecursively() + } + + /** + * One payload part of the retained set. + * + * @param json the parsed metadata + * @param flag the presence key written by [retain] + * @param name the part's file name inside [dir] + * @return the part, or null when the deploy carried none + * @throws IllegalStateException when the metadata claims a part the directory lacks - + * re-sending a payload missing its classes would advance the app past them + */ + private fun part( + json: JsonObject, + flag: String, + name: String, + ): File? { + if (!json.get(flag).asBoolean) return null + val file = File(dir, name) + check(file.isFile) { "retained $name is missing" } + return file + } + + private fun stagingDir(): File = File(dir.parentFile, dir.name + ".staging") + + companion object { + private val log = LoggerFactory.getLogger("QB-RetainedPayloads") + + private const val DEX_NAME = "payload.dex" + private const val ARSC_NAME = "payload.arsc" + private const val ASSETS_NAME = "assets.zip" + private const val META_NAME = "meta.json" + + /** + * The store for one executor work dir. A fixed relative path, so the executor writing + * retention and the session reading it agree across proxy app rebuilds, which rebuild + * the executor but keep the work dir. + * + * @param workDir the executor's payload-staging dir + * @return a store over `workDir/last-deployed` + */ + fun forWorkDir(workDir: File): RetainedPayloadStore = RetainedPayloadStore(File(workDir, "last-deployed")) + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppLauncher.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppLauncher.kt new file mode 100644 index 0000000000..f5f51f87fd --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppLauncher.kt @@ -0,0 +1,27 @@ +package org.appdevforall.cotg.quickbuild.service.provision + +/** + * Restarts the proxy app after a restart deploy, so a fresh process boots on the newest + * persisted generation - resuming the app's existing task where there is one, so the user + * comes back to the screen and back stack they left. + * + * Implemented in the app module because it needs a Context; the interface keeps the + * executor JVM-testable. + */ +fun interface ProxyAppLauncher { + /** + * Starts [packageName] again. + * + * @param packageName the installed proxy app's applicationId; the implementation launches + * it the way a home screen would, which is what resumes its task + * @param activityClass the launcher proxy FQN from the transformed manifest, or null when + * the launcher is an `` that no proxied activity carries. A fallback + * only, for an app that declares no launcher: an explicit component intent starts a + * screen rather than resuming a task. + * @return false when the launch could not be started at all + */ + fun launch( + packageName: String, + activityClass: String?, + ): Boolean +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/E2eTimelineRecorder.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/E2eTimelineRecorder.kt new file mode 100644 index 0000000000..24a04a2393 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/E2eTimelineRecorder.kt @@ -0,0 +1,189 @@ +package org.appdevforall.cotg.quickbuild.service.telemetry + +import org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline +import org.appdevforall.cotg.quickbuild.protocol.CompileStats +import org.appdevforall.cotg.quickbuild.protocol.DexStats + +/** + * Collects one build's timings as it moves through the pipeline, then mints an [E2eTimeline]. + * + * Not thread-safe, and does not need to be: the executor contract allows at most one build in + * flight. A route that never compiles skips [markCompileDone], so `compileDone` falls back to + * `deploySent` and compileMillis then measures relink plus package (see [E2eTimeline]). + * + * @param trigger the request's t0, in the same clock as the later marks + * @param scratchFsType read once at [completed] rather than at construction, so it reports + * the filesystem the daemon actually landed its output tree on + */ +internal class E2eTimelineRecorder( + private val trigger: Long, + private val scratchFsType: () -> String?, +) { + private var compileDone: Long? = null + private var deploySent: Long = trigger + private var steps = E2eTimeline.StepTimings() + private var spans = E2eTimeline.HostSpans() + private var counts = E2eTimeline.BuildCounts() + + /** + * Stamps t1, the moment a deployable dex exists. + * + * @param now the mark, in the same clock as `trigger`; a route that never compiles + * leaves this unset and `compileDone` then falls back to `deploySent` + */ + fun markCompileDone(now: Long) { + compileDone = now + } + + /** + * Stamps t2, immediately before the payload goes over the deploy channel. + * + * @param now the mark, in the same clock as `trigger` + */ + fun markDeploySent(now: Long) { + deploySent = now + } + + /** + * Records the wait from t0 until the build started - queueing, not work. + * + * @param millis host-observed span; the caller skips this when the request carries no + * trigger stamp, since there is then no t0 to measure from + */ + fun recordQueue(millis: Long) { + spans = spans.copy(queueMillis = millis) + } + + /** + * Records the source-tree walk that precedes the compile. + * + * @param millis host-observed span, not a daemon-reported one + */ + fun recordScan(millis: Long) { + spans = spans.copy(scanMillis = millis) + } + + /** + * Records the whole compile round trip to the daemon. + * + * @param millis host-observed span; the daemon's own kotlin/java steps nest inside it + */ + fun recordCompileRpc(millis: Long) { + spans = spans.copy(compileRpcMillis = millis) + } + + /** + * Records the hot-swap-versus-restart decision, including the class-header parses it + * needs. + * + * @param millis host-observed span + */ + fun recordPolicy(millis: Long) { + spans = spans.copy(policyMillis = millis) + } + + /** + * Records the whole dex round trip to the daemon. + * + * @param millis host-observed span; the daemon's strip and d8 steps nest inside it + */ + fun recordDexRpc(millis: Long) { + spans = spans.copy(dexRpcMillis = millis) + } + + /** + * Records the whole relink round trip to the daemon. + * + * @param millis host-observed span; the aapt2 compile and link steps nest inside it + */ + fun recordRelinkRpc(millis: Long) { + spans = spans.copy(relinkRpcMillis = millis) + } + + /** + * Records the daemon's own breakdown of one compile, and the source counts that go + * with it. + * + * @param kotlinMillis kotlinc's span, or null when no Kotlin source was compiled + * @param javaMillis javac's span, or null when no Java source was compiled + * @param stats the daemon's snapshot spans and counts; null leaves every derived field + * unset rather than zero, so a missing measurement never reads as a fast one + */ + fun recordCompileSteps( + kotlinMillis: Long?, + javaMillis: Long?, + stats: CompileStats?, + ) { + steps = + steps.copy( + kotlinMillis = kotlinMillis, + javaMillis = javaMillis, + preSnapMillis = stats?.preSnapMillis, + postSnapMillis = stats?.postSnapMillis, + javaAbiSnapMillis = stats?.javaAbiSnapMillis, + ) + counts = + counts.copy( + allSources = stats?.allSources, + kotlinDeclaredChanged = stats?.kotlinToCompile, + javaSources = stats?.javaSources, + changedClasses = stats?.changedClasses, + compileOrdinal = stats?.compileOrdinal, + ) + } + + /** + * Records the daemon's own breakdown of one dex step, and the class counts that go + * with it. + * + * @param stripMillis span of the class-stripping pass, or null when unreported + * @param d8Millis d8's span, or null when unreported + * @param stats the daemon's class-file and byte counts; null leaves both unset + */ + fun recordDexSteps( + stripMillis: Long?, + d8Millis: Long?, + stats: DexStats?, + ) { + steps = steps.copy(stripMillis = stripMillis, d8Millis = d8Millis) + counts = counts.copy(classFiles = stats?.classFiles, classBytes = stats?.classBytes) + } + + /** + * Records the daemon's own breakdown of one relink. + * + * @param aapt2CompileMillis aapt2's resource-compile span, or null when unreported + * @param aapt2LinkMillis aapt2's link span, or null when unreported + */ + fun recordRelinkSteps( + aapt2CompileMillis: Long?, + aapt2LinkMillis: Long?, + ) { + steps = steps.copy(aapt2CompileMillis = aapt2CompileMillis, aapt2LinkMillis = aapt2LinkMillis) + } + + /** + * Builds the finished timeline, stamping [reloadLive] as the last mark. + * + * @param generation the generation that went live, which keys the emitted line + * @param reloadLive t3, in the same clock as `trigger`: the moment the proxy app + * confirmed the new code is running + * @return the timeline to emit; empty step, span, and count groups are dropped rather + * than reported as zeros + */ + fun completed( + generation: Long, + reloadLive: Long, + ): E2eTimeline = + E2eTimeline( + generation = generation, + trigger = trigger, + compileDone = compileDone ?: deploySent, + deploySent = deploySent, + reloadLive = reloadLive, + steps = steps.takeUnless { it.isEmpty() }, + spans = spans.takeUnless { it.isEmpty() }, + counts = counts.takeUnless { it.isEmpty() }, + scratchFsType = scratchFsType(), + ) +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/MetricsReporting.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/MetricsReporting.kt new file mode 100644 index 0000000000..fcfef4d477 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/MetricsReporting.kt @@ -0,0 +1,25 @@ +package org.appdevforall.cotg.quickbuild.service.telemetry + +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +@PublishedApi +internal val metricsLog: Logger = LoggerFactory.getLogger("QB-Metrics") + +/** + * Runs a metrics call and swallows any failure into a logged warning, so metrics can + * never break a build. + * + * Every metrics call in this package goes through here rather than relying on each class + * to remember its own try/catch. + * + * @param block the metrics call; must be side-effect-free beyond reporting, since a + * partial run is swallowed and never retried + */ +internal inline fun report(block: () -> Unit) { + try { + block() + } catch (e: Throwable) { + metricsLog.warn("Quick Build metrics sink failed", e) + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/README.md b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/README.md new file mode 100644 index 0000000000..a94e812da8 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/README.md @@ -0,0 +1,8 @@ +# `service/telemetry/` - stamping and reporting a build's timeline + +This folder is the service-side counterpart to `domain/telemetry`: it stamps a timeline as a single build runs the pipeline and guards the reporting of it. One recorder collects per-step spans and counts and mints the finished `E2eTimeline`; a shared helper runs every metrics call so a misbehaving sink can never fail a build. + +| File | Purpose | +| --- | --- | +| [`E2eTimelineRecorder.kt`](E2eTimelineRecorder.kt) | Collects one build's timings (scan, compile, policy, dex, relink spans plus the daemon's own step breakdowns and source/class counts) as it moves through the pipeline, then builds the `E2eTimeline`. | +| [`MetricsReporting.kt`](MetricsReporting.kt) | `report {}` helper that runs a metrics call and swallows any failure into a logged warning, so metrics can never break a build. | diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/AssetPackagerTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/AssetPackagerTest.kt new file mode 100644 index 0000000000..cc59645a39 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/AssetPackagerTest.kt @@ -0,0 +1,112 @@ +package org.appdevforall.cotg.quickbuild.data + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.util.zip.ZipFile + +class AssetPackagerTest { + @TempDir lateinit var tempDir: File + + private val packager = AssetPackager() + private lateinit var assetsRoot: File + + @BeforeEach + fun setUp() { + assetsRoot = File(tempDir, "app/src/main/assets").apply { mkdirs() } + } + + private fun asset( + relative: String, + content: String = "content", + ): File = + File(assetsRoot, relative).apply { + parentFile!!.mkdirs() + writeText(content) + } + + @Test + fun `relativeAssetPath resolves nested paths with forward slashes`() { + val file = asset("data/levels.json") + assertThat(packager.relativeAssetPath(file, listOf(assetsRoot))) + .isEqualTo("data/levels.json") + } + + @Test + fun `relativeAssetPath is null for files outside the roots`() { + val source = File(tempDir, "app/src/main/java/Foo.kt") + assertThat(packager.relativeAssetPath(source, listOf(assetsRoot))).isNull() + } + + @Test + fun `packageAssets zips only the asset files from a mixed changed-set`() { + val levels = asset("data/levels.json", "levels") + val source = + File(tempDir, "app/src/main/java/Foo.kt").apply { + parentFile!!.mkdirs() + writeText("class Foo") + } + + val out = File(tempDir, "payload.zip") + val packaged = packager.packageAssets(listOf(levels, source), listOf(assetsRoot), out) + + assertThat(packaged).isNotNull() + assertThat(packaged!!.relativePaths).containsExactly("data/levels.json") + ZipFile(out).use { zip -> + val entry = zip.getEntry("data/levels.json") + assertThat(entry).isNotNull() + assertThat(zip.getInputStream(entry).readBytes().decodeToString()).isEqualTo("levels") + } + } + + @Test + fun `packageAssets returns null when no asset changed`() { + val source = File(tempDir, "Foo.kt").apply { writeText("class Foo") } + val out = File(tempDir, "payload.zip") + assertThat(packager.packageAssets(listOf(source), listOf(assetsRoot), out)).isNull() + assertThat(out.exists()).isFalse() + } + + @Test + fun `packageAssets skips deleted files but keeps existing ones`() { + val kept = asset("kept.txt", "kept") + val deleted = File(assetsRoot, "deleted.txt") + + val out = File(tempDir, "payload.zip") + val packaged = packager.packageAssets(listOf(kept, deleted), listOf(assetsRoot), out) + + assertThat(packaged).isNotNull() + ZipFile(out).use { zip -> + assertThat(zip.getEntry("kept.txt")).isNotNull() + assertThat(zip.getEntry("deleted.txt")).isNull() + } + } + + @Test + fun `a path that climbs out of the asset root is not an asset`() { + // Raw text, the escape passes a startsWith check against the root and would name a + // zip entry the runtime unpacks outside its asset directory. + val escaping = File(assetsRoot, "sub/../../../evil.txt") + + assertThat(packager.relativeAssetPath(escaping, listOf(assetsRoot))).isNull() + } + + @Test + fun `a path that climbs but stays inside keeps its resolved name`() { + val inside = File(assetsRoot, "sub/../data/levels.json") + + assertThat(packager.relativeAssetPath(inside, listOf(assetsRoot))) + .isEqualTo("data/levels.json") + } + + @Test + fun `an escaping path is packaged as no asset at all`() { + val escaping = File(assetsRoot, "sub/../../../evil.txt") + val out = File(tempDir, "payload.zip") + + assertThat(packager.packageAssets(listOf(escaping), listOf(assetsRoot), out)).isNull() + assertThat(out.exists()).isFalse() + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/ClassHeaderEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/ClassHeaderEdgeTest.kt new file mode 100644 index 0000000000..392b119027 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/ClassHeaderEdgeTest.kt @@ -0,0 +1,143 @@ +package org.appdevforall.cotg.quickbuild.domain.reload + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream + +/** + * Hand-crafted class bytes for the constant-pool shapes real kotlinc fixtures cannot + * produce on demand: MethodHandle/MethodType entries, an unknown tag, a broken + * this_class, and a zero super_class. Complements [ClassHeaderTest]'s real-fixture + * coverage. + */ +class ClassHeaderEdgeTest { + private class ClassBytes { + private val pool = mutableListOf<(DataOutputStream) -> Unit>() + + /** 1-based index of the entry just added. */ + private fun add(writer: (DataOutputStream) -> Unit): Int { + pool += writer + return pool.size + } + + fun utf8(value: String) = + add { + it.writeByte(1) + it.writeUTF(value) + } + + fun classRef(nameIndex: Int) = + add { + it.writeByte(7) + it.writeShort(nameIndex) + } + + fun stringRef(utf8Index: Int) = + add { + it.writeByte(8) + it.writeShort(utf8Index) + } + + fun methodHandle() = + add { + it.writeByte(15) + it.writeByte(1) + it.writeShort(0) + } + + fun methodType(descriptorIndex: Int) = + add { + it.writeByte(16) + it.writeShort(descriptorIndex) + } + + fun unknownTag() = add { it.writeByte(99) } + + fun build( + thisClass: Int, + superClass: Int, + interfaces: List = emptyList(), + ): ByteArray { + val bytes = ByteArrayOutputStream() + DataOutputStream(bytes).use { out -> + out.writeInt(-0x35014542) // 0xCAFEBABE + out.writeShort(0) // minor + out.writeShort(52) // major + out.writeShort(pool.size + 1) + pool.forEach { it(out) } + out.writeShort(0x0021) // access flags + out.writeShort(thisClass) + out.writeShort(superClass) + out.writeShort(interfaces.size) + interfaces.forEach(out::writeShort) + } + return bytes.toByteArray() + } + } + + @Test + fun `method handle and method type entries are skipped without derailing the walk`() { + val b = ClassBytes() + val name = b.utf8("com/example/Made") + val thisClass = b.classRef(name) + b.stringRef(name) + b.methodHandle() + b.methodType(name) + val objectName = b.utf8("java/lang/Object") + val objectClass = b.classRef(objectName) + + val header = ClassHeader.parse(b.build(thisClass, objectClass)) + + assertThat(header).isNotNull() + assertThat(header!!.className).isEqualTo("com.example.Made") + assertThat(header.superClassName).isEqualTo("java.lang.Object") + } + + @Test + fun `an unknown constant-pool tag parses to null, never a throw`() { + val b = ClassBytes() + val name = b.utf8("com/example/Made") + val thisClass = b.classRef(name) + b.unknownTag() + + assertThat(ClassHeader.parse(b.build(thisClass, 0))).isNull() + } + + @Test + fun `a this_class that is not a Class entry parses to null`() { + val b = ClassBytes() + val name = b.utf8("com/example/Made") + b.classRef(name) + + // this_class points at the Utf8 entry, not the Class entry. + assertThat(ClassHeader.parse(b.build(thisClass = name, superClass = 0))).isNull() + } + + @Test + fun `a zero super_class reports no superclass`() { + // java/lang/Object itself carries super_class = 0. + val b = ClassBytes() + val name = b.utf8("java/lang/Object") + val thisClass = b.classRef(name) + + val header = ClassHeader.parse(b.build(thisClass, superClass = 0)) + + assertThat(header).isNotNull() + assertThat(header!!.superClassName).isNull() + } + + @Test + fun `an interface entry with a dangling index is skipped, not fatal`() { + val b = ClassBytes() + val name = b.utf8("com/example/Made") + val thisClass = b.classRef(name) + val ifaceName = b.utf8("java/io/Serializable") + val iface = b.classRef(ifaceName) + + val header = ClassHeader.parse(b.build(thisClass, superClass = 0, interfaces = listOf(iface, 0))) + + assertThat(header).isNotNull() + assertThat(header!!.interfaceNames).containsExactly("java.io.Serializable") + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/ClassHeaderTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/ClassHeaderTest.kt new file mode 100644 index 0000000000..f61177f73a --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/ClassHeaderTest.kt @@ -0,0 +1,77 @@ +package org.appdevforall.cotg.quickbuild.domain.reload + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.io.Serializable + +/** + * Parses REAL class files - this test's own compiled fixtures, loaded from the test + * classpath - so the constant-pool walk is verified against genuine kotlinc output, + * not hand-crafted bytes. + */ +class ClassHeaderTest { + private open class Base + + private class Sample : + Base(), + Serializable + + private fun bytesOf(clazz: Class<*>): ByteArray { + val resource = clazz.name.replace('.', '/') + ".class" + return clazz.classLoader.getResourceAsStream(resource)!!.use { it.readBytes() } + } + + @Test + fun `parses name, superclass and interfaces of a real nested class`() { + val header = ClassHeader.parse(bytesOf(Sample::class.java)) + + assertThat(header).isNotNull() + assertThat(header!!.className) + .isEqualTo("org.appdevforall.cotg.quickbuild.domain.reload.ClassHeaderTest\$Sample") + assertThat(header.superClassName) + .isEqualTo("org.appdevforall.cotg.quickbuild.domain.reload.ClassHeaderTest\$Base") + assertThat(header.interfaceNames).containsExactly("java.io.Serializable") + } + + @Test + fun `a plain class reports Object as superclass and no interfaces`() { + val header = ClassHeader.parse(bytesOf(Base::class.java)) + + assertThat(header).isNotNull() + assertThat(header!!.superClassName).isEqualTo("java.lang.Object") + assertThat(header.interfaceNames).isEmpty() + } + + @Test + fun `constant-pool entries with two slots do not derail the walk`() { + // String/numeric constants (incl. Long and Double, which occupy two slots) + // populate the pool ahead of the header fields. + val header = ClassHeader.parse(bytesOf(ConstantsFixture::class.java)) + + assertThat(header).isNotNull() + assertThat(header!!.className) + .isEqualTo("org.appdevforall.cotg.quickbuild.domain.reload.ConstantsFixture") + } + + @Test + fun `garbage bytes parse to null, never a throw`() { + assertThat(ClassHeader.parse(ByteArray(0))).isNull() + assertThat(ClassHeader.parse(byteArrayOf(1, 2, 3, 4, 5))).isNull() + assertThat(ClassHeader.parse("not a class file at all".toByteArray())).isNull() + } + + @Test + fun `a truncated class file parses to null`() { + val bytes = bytesOf(Sample::class.java) + + assertThat(ClassHeader.parse(bytes.copyOf(12))).isNull() + } +} + +/** Fixture whose constant pool carries long/double constants (two-slot entries). */ +@Suppress("unused") +private class ConstantsFixture { + val longConstant: Long = 0x1234_5678_9ABCL + val doubleConstant: Double = 3.14159265358979 + val stringConstant: String = "quick-build" +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/DeployPolicyTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/DeployPolicyTest.kt new file mode 100644 index 0000000000..ec57da0217 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/DeployPolicyTest.kt @@ -0,0 +1,223 @@ +package org.appdevforall.cotg.quickbuild.domain.reload + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * Contract tests for the restart-vs-recreate decision (see component-proxying-design.md, + * "Restart vs recreate"): restart iff the app declares a service, provider or custom + * `Application`, whatever the compile touched. Receivers and activities never restart. + * + * The rule deliberately ignores the recompiled set, because every generation ships the whole + * user class set. The tests below therefore pin the decision against edits that a + * closure-intersection rule would have called a hot swap - those are the regressions that + * reintroduce the measured `ClassCastException`. + */ +class DeployPolicyTest { + private val service = + ComponentInfo( + ComponentKind.SERVICE, + "com.example.SyncService", + proxyClass = "com.example.quickbuild.proxies.Proxy0Service", + supertypes = listOf("com.example.BaseService"), + ) + private val provider = + ComponentInfo( + ComponentKind.PROVIDER, + "com.example.DataProvider", + proxyClass = "com.example.quickbuild.proxies.Proxy0Provider", + ) + private val application = ComponentInfo(ComponentKind.APPLICATION, "com.example.App") + private val receiver = + ComponentInfo( + ComponentKind.RECEIVER, + "com.example.BootReceiver", + proxyClass = "com.example.quickbuild.proxies.Proxy0Receiver", + ) + private val activity = + ComponentInfo( + ComponentKind.ACTIVITY, + "com.example.MainActivity", + proxyClass = "com.example.quickbuild.proxies.Proxy0Activity", + launcher = true, + supertypes = listOf("com.example.BaseActivity"), + ) + + private val logSenderService = + ComponentInfo( + ComponentKind.SERVICE, + "com.itsaky.androidide.logsender.LogSenderService", + proxyClass = "com.example.quickbuild.proxies.Proxy1Service", + ) + private val logSenderInstaller = + ComponentInfo( + ComponentKind.PROVIDER, + "com.itsaky.androidide.logsender.utils.LogSenderInstaller", + proxyClass = "com.example.quickbuild.proxies.Proxy1Provider", + ) + + private fun policy(vararg components: ComponentInfo) = DeployPolicy(components.toList()) + + @Test + fun `an edit far from the Application still restarts - the payload redefines it anyway`() { + // The regression this rule exists for: an activity-only edit, reproduced on device as + // `ClassCastException: ProbeApp cannot be cast to ProbeApp`. A closure-intersection + // rule answers Recreate here, because the recompiled set never names the Application. + val policy = policy(activity, application) + + assertThat(policy.decide(listOf("com/example/MainActivity.class"))) + .isEqualTo(DeployDecision.Restart(ComponentKind.APPLICATION, "com.example.App")) + assertThat(policy.decide(listOf("com/example/util/Formatter.class"))) + .isEqualTo(DeployDecision.Restart(ComponentKind.APPLICATION, "com.example.App")) + } + + @Test + fun `an edit far from a service or provider restarts too`() { + assertThat(policy(activity, service).decide(listOf("com/example/util/Formatter.class"))) + .isEqualTo(DeployDecision.Restart(ComponentKind.SERVICE, "com.example.SyncService")) + assertThat(policy(activity, provider).decide(listOf("com/example/util/Formatter.class"))) + .isEqualTo(DeployDecision.Restart(ComponentKind.PROVIDER, "com.example.DataProvider")) + } + + @Test + fun `every restart-sensitive kind restarts on its own`() { + RESTART_SENSITIVE_KINDS.forEach { kind -> + val component = ComponentInfo(kind, "com.example.Held") + assertThat(policy(activity, component).decide(listOf("com/example/Unrelated.class"))) + .isEqualTo(DeployDecision.Restart(kind, "com.example.Held")) + } + } + + @Test + fun `the component class itself recompiled - restart naming it`() { + assertThat(policy(activity, service, receiver).decide(listOf("com/example/SyncService.class"))) + .isEqualTo(DeployDecision.Restart(ComponentKind.SERVICE, "com.example.SyncService")) + assertThat(policy(provider).decide(listOf("com/example/DataProvider.class"))) + .isEqualTo(DeployDecision.Restart(ComponentKind.PROVIDER, "com.example.DataProvider")) + assertThat(policy(activity, application).decide(listOf("com/example/App.class"))) + .isEqualTo(DeployDecision.Restart(ComponentKind.APPLICATION, "com.example.App")) + } + + @Test + fun `no restart-sensitive component - every code deploy hot swaps`() { + val policy = policy(activity, receiver) + + assertThat(policy.decide(listOf("com/example/MainActivity.class"))).isEqualTo(DeployDecision.Recreate) + assertThat(policy.decide(listOf("com/example/BootReceiver.class"))).isEqualTo(DeployDecision.Recreate) + assertThat(policy.decide(emptyList())).isEqualTo(DeployDecision.Recreate) + assertThat(policy.decide(null)).isEqualTo(DeployDecision.Recreate) + } + + @Test + fun `a component list with no components at all hot swaps`() { + assertThat(policy().decide(listOf("com/example/MainActivity.class"))) + .isEqualTo(DeployDecision.Recreate) + } + + @Test + fun `a compile that emitted nothing still restarts - the dex is rebuilt whole`() { + // The dex step walks the compiler's output tree, so an empty recompiled set still + // ships every user class through a fresh loader and still breaks a held instance. + assertThat(policy(activity, application).decide(emptyList())) + .isEqualTo(DeployDecision.Restart(ComponentKind.APPLICATION, "com.example.App")) + } + + @Test + fun `an unknown recompiled set restarts`() { + assertThat(policy(activity, service).decide(null)) + .isEqualTo(DeployDecision.Restart(ComponentKind.SERVICE, "com.example.SyncService")) + } + + @Test + fun `the first restart-sensitive component in declaration order names the cause`() { + assertThat(policy(activity, provider, service, application).decide(null)) + .isEqualTo(DeployDecision.Restart(ComponentKind.PROVIDER, "com.example.DataProvider")) + assertThat(policy(activity, application, service).decide(null)) + .isEqualTo(DeployDecision.Restart(ComponentKind.APPLICATION, "com.example.App")) + } + + @Test + fun `pre-v2 baseline - any code-bearing deploy routes to a proxy app rebuild`() { + val policy = DeployPolicy(emptyList(), componentInfoAvailable = false) + + assertThat(policy.decide(listOf("com/example/Foo.class"))) + .isInstanceOf(DeployDecision.RebuildProxyApp::class.java) + assertThat(policy.decide(null)).isInstanceOf(DeployDecision.RebuildProxyApp::class.java) + } + + @Test + fun `pre-v2 baseline - a compile that emitted nothing is not worth a rebuild`() { + assertThat(DeployPolicy(emptyList(), componentInfoAvailable = false).decide(emptyList())) + .isEqualTo(DeployDecision.Recreate) + } + + @Test + fun `pre-v2 wins over a known component - that runtime cannot honour a restart`() { + // The old runtime hot-swaps a restart deploy instead of exiting, so asking it to + // restart would leave the component stale AND claim it did not. Rebuild instead. + val policy = DeployPolicy(listOf(service), componentInfoAvailable = false) + + assertThat(policy.decide(listOf("com/example/SyncService.class"))) + .isInstanceOf(DeployDecision.RebuildProxyApp::class.java) + } + + @Test + fun `an app whose only service and provider are CoGo's own hot swaps`() { + // Logsender is injected into every debuggable build, so without the exemption every app + // restarts on every save. Its classes live in the base APK dex and no payload redefines + // them, so nothing can go stale. + val policy = policy(activity, logSenderService, logSenderInstaller) + + assertThat(policy.decide(listOf("com/example/MainActivity.class"))).isEqualTo(DeployDecision.Recreate) + assertThat(policy.decide(null)).isEqualTo(DeployDecision.Recreate) + } + + @Test + fun `a user-declared service still restarts alongside CoGo's own`() { + assertThat(policy(logSenderInstaller, logSenderService, service).decide(null)) + .isEqualTo(DeployDecision.Restart(ComponentKind.SERVICE, "com.example.SyncService")) + assertThat(policy(logSenderInstaller, application).decide(null)) + .isEqualTo(DeployDecision.Restart(ComponentKind.APPLICATION, "com.example.App")) + } + + @Test + fun `the exemption is by exact class name, not by package`() { + // A user class that happens to sit in logsender's package is still the user's code and + // still ships in the payload. A prefix match would silently stop restarting for it. + val neighbour = + ComponentInfo(ComponentKind.SERVICE, "com.itsaky.androidide.logsender.MyOwnService") + val nestedNeighbour = + ComponentInfo(ComponentKind.PROVIDER, "com.itsaky.androidide.logsender.utils.MyOwnProvider") + + assertThat(policy(logSenderService, neighbour).decide(null)) + .isEqualTo( + DeployDecision.Restart(ComponentKind.SERVICE, "com.itsaky.androidide.logsender.MyOwnService"), + ) + assertThat(policy(logSenderInstaller, nestedNeighbour).decide(null)) + .isEqualTo( + DeployDecision.Restart( + ComponentKind.PROVIDER, + "com.itsaky.androidide.logsender.utils.MyOwnProvider", + ), + ) + } + + @Test + fun `the exempt names are the ones CoGo actually injects`() { + // Pinned against the logsender AAR's merged manifest; a rename there that misses this + // set silently restores restart-on-every-save. + assertThat(COGO_INJECTED_COMPONENTS) + .containsExactly( + "com.itsaky.androidide.logsender.LogSenderService", + "com.itsaky.androidide.logsender.utils.LogSenderInstaller", + ) + } + + @Test + fun `backslash-separated class paths do not change the decision`() { + assertThat(policy(service).decide(listOf("com\\example\\SyncService.class"))) + .isEqualTo(DeployDecision.Restart(ComponentKind.SERVICE, "com.example.SyncService")) + assertThat(policy(activity).decide(listOf("com\\example\\MainActivity.class"))) + .isEqualTo(DeployDecision.Recreate) + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTrackerTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTrackerTest.kt new file mode 100644 index 0000000000..b47b5f452e --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTrackerTest.kt @@ -0,0 +1,96 @@ +package org.appdevforall.cotg.quickbuild.domain.reload + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class GenerationTrackerTest { + private class FakeStore( + private var stored: Long? = null, + ) : GenerationStore { + val saves: MutableList = mutableListOf() + + override fun load(): Long? = stored + + override fun save(generation: Long) { + saves.add(generation) + stored = generation + } + } + + @Test + fun `fresh store starts at generation 0 and next returns 1`() { + val store = FakeStore() + val tracker = GenerationTracker(store) + + assertThat(tracker.current).isEqualTo(0L) + + val next = tracker.next() + + assertThat(next).isEqualTo(1L) + assertThat(store.saves).isEqualTo(listOf(1L)) + } + + @Test + fun `next is monotonic across calls`() { + val store = FakeStore() + val tracker = GenerationTracker(store) + + assertThat(tracker.next()).isEqualTo(1L) + assertThat(tracker.current).isEqualTo(1L) + + assertThat(tracker.next()).isEqualTo(2L) + assertThat(tracker.current).isEqualTo(2L) + + assertThat(tracker.next()).isEqualTo(3L) + assertThat(tracker.current).isEqualTo(3L) + } + + @Test + fun `resumes from a store with an existing generation`() { + val store = FakeStore(stored = 41L) + val tracker = GenerationTracker(store) + + assertThat(tracker.current).isEqualTo(41L) + assertThat(tracker.next()).isEqualTo(42L) + } + + @Test + fun `persists before next returns`() { + val store = FakeStore() + val tracker = GenerationTracker(store) + + val next = tracker.next() + + assertThat(next).isEqualTo(1L) + assertThat(store.saves).isEqualTo(listOf(1L)) + } + + @Test + fun `adoptAtLeast moves the counter past a stamped baseline and persists it`() { + // A rebaseline stamps generation 8 through the host-side allocator while this + // (session) tracker still sits at 7; without adoption the next deploy would be 8, + // equal to the baseline, and the runtime would reject it as stale. + val store = FakeStore(stored = 7L) + val tracker = GenerationTracker(store) + + tracker.adoptAtLeast(8L) + + assertThat(tracker.current).isEqualTo(8L) + assertThat(store.saves).isEqualTo(listOf(8L)) + assertThat(tracker.next()).isEqualTo(9L) + } + + @Test + fun `adoptAtLeast is a no-op at or below the current counter`() { + val store = FakeStore(stored = 5L) + val tracker = GenerationTracker(store) + + // An unstamped (0) baseline and a stale stamp must not move or re-save the counter. + tracker.adoptAtLeast(0L) + tracker.adoptAtLeast(5L) + + assertThat(tracker.current).isEqualTo(5L) + assertThat(store.saves).isEmpty() + assertThat(tracker.next()).isEqualTo(6L) + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestratorTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestratorTest.kt new file mode 100644 index 0000000000..f1e1a76a91 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestratorTest.kt @@ -0,0 +1,2206 @@ +@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + +package org.appdevforall.cotg.quickbuild.domain.reload + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.classify.ChangeClassifier +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.service.telemetry.report +import org.junit.jupiter.api.Test +import java.io.File + +/** + * Pins the concurrency model: the pending changed-set is never lost - not by a save landing + * mid-build, not by a failed compile, not by a superseded build. + * + * The ways that is easy to break: clearing changedSrc BEFORE a compile drops the user's edits + * when it fails, conflating an empty changed-set with an unknown one runs spurious full + * recompiles, and an untagged result lets a superseded build's outcome land anyway. + */ +class LiveReloadOrchestratorTest { + private class GatedExecutor : LiveReloadExecutor { + val requests = mutableListOf() + val gates = mutableListOf>() + var cancellations = 0 + var throwOnNext: Throwable? = null + var promotions = 0 + + /** + * How many builds ran all the way to returning an outcome - the stand-in for a payload + * reaching the proxy app. An abandoned build must never get this far. + */ + var deploys = 0 + + override fun markCurrentBuildUserInitiated() { + promotions++ + } + + override suspend fun execute(request: BuildRequest): BuildOutcome { + requests += request + throwOnNext?.let { error -> + throwOnNext = null + throw error + } + val gate = CompletableDeferred() + gates += gate + try { + val outcome = gate.await() + deploys++ + return outcome + } catch (e: CancellationException) { + cancellations++ + throw e + } + } + + fun finish( + index: Int, + outcome: BuildOutcome, + ) { + gates[index].complete(outcome) + } + } + + private fun known(vararg paths: String) = ChangedFiles.Known(paths.map(::File).toSet()) + + private fun success(generation: Long = 1L) = BuildOutcome.Success(generation = generation, durationMillis = 100) + + private fun compileError() = + BuildOutcome.CompileError( + listOf(BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "expecting ')'", "B.kt", 7, 13)), + ) + + /** + * A relink that fails for something no edit can reach - the daemon could not link at all, + * as opposed to aapt2 rejecting the user's XML (which is a [compileError]). + */ + private fun relinkFailure() = BuildOutcome.InfrastructureFailure("relink: library resource snapshot is missing R.txt") + + /** A deploy that did not land, without the not-connected shape that escalates on a repeat. */ + private fun deployFailure() = BuildOutcome.DeployFailure("the payload could not be written") + + private fun notConnected() = + BuildOutcome.DeployFailure( + "Proxy app is not connected. Relaunch your app to reconnect, then deploy again.", + proxyAppNotConnected = true, + ) + + /** + * aapt2 rejecting the project's resources. Every error names a file under `res/`, which is + * how the orchestrator tells an aapt2 rejection from a kotlinc one - the two never mix in + * one outcome, because a failed compile returns before the relink runs. + */ + private fun resourceError() = + BuildOutcome.CompileError( + listOf( + BuildDiagnostic( + BuildDiagnostic.Severity.ERROR, + "resource style/Theme.Library not found", + resLayout, + 12, + 5, + ), + ), + ) + + private val resLayout = "app/src/main/res/layout/activity_main.xml" + private val srcA = "app/src/main/java/com/example/A.kt" + private val srcB = "app/src/main/java/com/example/B.kt" + private val srcC = "app/src/main/java/com/example/C.kt" + + @Test + fun `a save starts a build with exactly the saved files`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(srcA, srcB)) + runCurrent() + + assertThat(executor.requests).hasSize(1) + assertThat(executor.requests[0].changes).isEqualTo(known(srcA, srcB)) + assertThat(executor.requests[0].route).isEqualTo(BuildRoute.CodeOnly) + assertThat(events).containsExactly( + OrchestratorEvent.BuildStarted(1L, BuildRoute.CodeOnly, known(srcA, srcB)), + ) + } + + @Test + fun `a build's trigger stamp is the arriving change's time - e2e t0`() = + runTest { + var nowMs = 100L + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope, now = { nowMs }) {} + + nowMs = 100L + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + + assertThat(executor.requests.single().triggeredAtMillis).isEqualTo(100L) + } + + @Test + fun `a coalesced follow-up's trigger is its EARLIEST mid-build change - not the change that landed later`() = + runTest { + var nowMs = 100L + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope, now = { nowMs }) {} + + orchestrator.onFilesChanged(known(srcA)) // starts build 0 at t=100 + runCurrent() + nowMs = 200L + orchestrator.onFilesChanged(known(srcB)) // first of the mid-build batch + nowMs = 300L + orchestrator.onFilesChanged(known(srcC)) // coalesces; must not reset t0 + runCurrent() + + executor.finish(0, success(generation = 1)) + runCurrent() + + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[0].triggeredAtMillis).isEqualTo(100L) + // The follow-up waited from srcB's arrival (200), not srcC's (300). + assertThat(executor.requests[1].triggeredAtMillis).isEqualTo(200L) + } + + @Test + fun `a forced catch-up on an empty queue is stamped at the request time`() = + runTest { + // The reconnect catch-up is the one remaining caller that forces a build of an + // empty set; a user tap with nothing pending builds nothing at all. + var nowMs = 500L + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope, now = { nowMs }) {} + + orchestrator.onLiveReloadRequested(userInitiated = false) + runCurrent() + + assertThat(executor.requests.single().triggeredAtMillis).isEqualTo(500L) + } + + @Test + fun `a failed build's trigger is not inherited by the save that follows it`() = + runTest { + // The T16 defect: the failed attempt's batch returns to pending, and with it its t0. + // The next build then measured from that dead stamp, so the pane reported 197.3s of + // queueing for a 2.25s save - about 100x, and the number the feature is judged on. + var nowMs = 100L + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope, now = { nowMs }) {} + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + nowMs = 2_300L + executor.finish(0, deployFailure()) + runCurrent() + + // The user reads the error and fixes the code; none of that is queueing. + nowMs = 197_500L + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].triggeredAtMillis).isEqualTo(197_500L) + } + + @Test + fun `a save that queued behind a failing build keeps its own trigger`() = + runTest { + // The other half of the fix: a mid-build save really did wait behind the in-flight + // build, so dropping ITS stamp too would under-report a queue that was genuine. + var nowMs = 100L + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope, now = { nowMs }) {} + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + nowMs = 200L + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + nowMs = 300L + executor.finish(0, compileError()) + runCurrent() + + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].triggeredAtMillis).isEqualTo(200L) + } + + @Test + fun `a tap after a failed build is stamped at the tap, not at the dead build`() = + runTest { + var nowMs = 100L + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope, now = { nowMs }) {} + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + nowMs = 400L + executor.finish(0, deployFailure()) + runCurrent() + nowMs = 61_400L + orchestrator.onLiveReloadRequested() + runCurrent() + + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].triggeredAtMillis).isEqualTo(61_400L) + } + + @Test + fun `a build picked up with no queue clock is stamped at its own start`() = + runTest { + // Nothing arrived after the failure, so the returned batch carries no clock at all; + // the warm-compile request is what happens to start the build. Its t0 is that moment, + // which reports the wait as the zero it was rather than as a missing measurement. + var nowMs = 100L + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope, now = { nowMs }) {} + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + nowMs = 400L + executor.finish(0, deployFailure()) + runCurrent() + nowMs = 5_000L + orchestrator.onWarmCompileRequested() + runCurrent() + + assertThat(executor.requests).hasSize(2) + // The real batch outranks the warm compile, so this is a code build, not a warm one. + assertThat(executor.requests[1].route).isEqualTo(BuildRoute.CodeOnly) + assertThat(executor.requests[1].triggeredAtMillis).isEqualTo(5_000L) + } + + @Test + fun `a stop tap does not leave its trigger for the next save`() = + runTest { + var nowMs = 100L + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope, now = { nowMs }) {} + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + nowMs = 300L + orchestrator.onCancelRequested() + runCurrent() + nowMs = 60_300L + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].triggeredAtMillis).isEqualTo(60_300L) + } + + @Test + fun `a failed proxy app rebuild does not charge its own duration to the next save`() = + runTest { + // A rebuild runs for minutes. Its held batch coming back must not come back with a + // clock that has been running the whole time. + var nowMs = 100L + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope, now = { nowMs }) {} + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + nowMs = 150L + orchestrator.onProxyAppRebuildStarted() + runCurrent() + nowMs = 200_000L + orchestrator.onProxyAppRebuildFailed() + runCurrent() + nowMs = 200_100L + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].triggeredAtMillis).isEqualTo(200_100L) + } + + @Test + fun `an external build's hand-back does not start a queue clock of its own`() = + runTest { + // onBaselineUntrusted starts no build, so a clock started there would run until the + // user's next save and be charged to it. + var nowMs = 1_000L + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope, now = { nowMs }) {} + + orchestrator.onBaselineUntrusted() + runCurrent() + nowMs = 91_000L + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + + assertThat(executor.requests.single().triggeredAtMillis).isEqualTo(91_000L) + } + + @Test + fun `save during in-flight build coalesces and never cancels the running compile`() = + runTest { + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + orchestrator.onFilesChanged(known(srcB)) + orchestrator.onFilesChanged(known(srcC)) + runCurrent() + + // Still one build in flight; nothing was cancelled. + assertThat(executor.requests).hasSize(1) + assertThat(executor.cancellations).isEqualTo(0) + + executor.finish(0, success(generation = 1)) + runCurrent() + + // Both mid-build edits are present in the coalesced follow-up. + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].changes).isEqualTo(known(srcB, srcC)) + } + + @Test + fun `a file modified in one mid-build batch then deleted in the next is only removed in the follow-up`() = + runTest { + // Pending accumulates across coalesced batches while a build is in flight. A plain + // set union would carry srcB as BOTH modified and removed, and the executor would + // feed it to the daemon compile as changed and removed at once. + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + orchestrator.onFilesChanged(known(srcB)) // batch 1: srcB modified + orchestrator.onFilesChanged(ChangedFiles.Known(emptySet(), setOf(File(srcB)))) // batch 2: srcB deleted + runCurrent() + + executor.finish(0, success(generation = 1)) + runCurrent() + + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].changes) + .isEqualTo(ChangedFiles.Known(emptySet(), setOf(File(srcB)))) + } + + @Test + fun `multi-file batch survives a failed compile - nothing is dropped`() = + runTest { + // Clearing changedSrc before the compile would drop every file in the batch + // the moment that compile fails. + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(known(srcA, srcB)) + runCurrent() + executor.finish(0, compileError()) + runCurrent() + + // No new saves arrived mid-build: the orchestrator waits (retrying the identical + // batch would fail identically). The failed batch is back in pending. + assertThat(executor.requests).hasSize(1) + + // The user fixes B - the next build carries the WHOLE failed batch, not just B. + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].changes).isEqualTo(known(srcA, srcB)) + } + + @Test + fun `plan 1-4 sequence - failed batch unions with mid-build save, fix rebuilds everything`() = + runTest { + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + // save A, B -> build #1 {A, B} + orchestrator.onFilesChanged(known(srcA, srcB)) + runCurrent() + // save C mid-build + orchestrator.onFilesChanged(known(srcC)) + runCurrent() + // build #1 FAILS (typo in B) + executor.finish(0, compileError()) + runCurrent() + + // C arrived mid-build and may contain the fix: rebuild immediately from the + // accumulated set {A, B, C}. (Deviation from the plan's diagram, which waits + // for the next save - documented in the ticket status doc, wrapper repo.) + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].changes).isEqualTo(known(srcA, srcB, srcC)) + + // B is still broken -> build #2 fails; no new mid-build saves -> wait. + executor.finish(1, compileError()) + runCurrent() + assertThat(executor.requests).hasSize(2) + + // User fixes B -> build #3 carries the full accumulated set. + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + assertThat(executor.requests).hasSize(3) + assertThat(executor.requests[2].changes).isEqualTo(known(srcA, srcB, srcC)) + + executor.finish(2, success(generation = 1)) + runCurrent() + assertThat(executor.requests).hasSize(3) + } + + @Test + fun `no-op save does not trigger a build`() = + runTest { + // Conflating an empty changed-set with an unknown one turns a no-op save + // into a spurious full recompile. + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(ChangedFiles.Known.EMPTY) + orchestrator.onFilesChanged(ChangedFiles.Known.EMPTY) + runCurrent() + + assertThat(executor.requests).isEmpty() + } + + @Test + fun `unknown changes force a full recompile on the live reload path`() = + runTest { + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(ChangedFiles.Unknown) + runCurrent() + + assertThat(executor.requests).hasSize(1) + assertThat(executor.requests[0].changes).isEqualTo(ChangedFiles.Unknown) + assertThat(executor.requests[0].route).isEqualTo(BuildRoute.CodeAndResources) + } + + @Test + fun `rapid save burst coalesces into a single follow-up build`() = + runTest { + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + val burst = (1..10).map { "app/src/main/java/com/example/Burst$it.kt" } + for (path in burst) { + orchestrator.onFilesChanged(known(path)) + } + runCurrent() + + // No queue growth: one in flight, everything else coalesced. + assertThat(executor.requests).hasSize(1) + + executor.finish(0, success(generation = 1)) + runCurrent() + + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].changes).isEqualTo(known(*burst.toTypedArray())) + } + + @Test + fun `manifest change requests invalidation instead of a quick build, exactly once`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml")) + runCurrent() + + assertThat(executor.requests).isEmpty() + assertThat(events).containsExactly( + OrchestratorEvent.InvalidationRequired(InvalidationReason.MANIFEST_CHANGED), + ) + + // More saves while invalidated: no duplicate event, still no quick build. + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + assertThat(executor.requests).isEmpty() + assertThat(events).hasSize(1) + } + + @Test + fun `after a baseline reset the session builds normally again`() = + runTest { + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml")) + runCurrent() + orchestrator.onProxyAppRebuildStarted() + orchestrator.onBaselineReset() + runCurrent() + + // The manifest edit was absorbed by the proxy app rebuild; a fresh code save builds. + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + + assertThat(executor.requests).hasSize(1) + assertThat(executor.requests[0].changes).isEqualTo(known(srcA)) + } + + @Test + fun `save landing mid-rebuild is kept and quick-built right after the reset`() = + runTest { + // The Gradle build only absorbs what existed when it + // STARTED; a save landing while it runs must not be dropped with the batch. + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml")) + runCurrent() + orchestrator.onProxyAppRebuildStarted() + orchestrator.onFilesChanged(known(srcA)) // mid-rebuild save + runCurrent() + assertThat(executor.requests).isEmpty() // still invalidated: no quick build yet + + orchestrator.onBaselineReset() + runCurrent() + + assertThat(executor.requests).hasSize(1) + assertThat(executor.requests[0].changes).isEqualTo(known(srcA)) + } + + @Test + fun `a save echo predating the rebuild start is absorbed, not resurfaced as a spurious invalidation`() = + runTest { + // F4: the tap's own build.gradle.kts save echo, debounced past onProxyAppRebuildStarted, + // stranded in pending and came back from onBaselineReset as a GRADLE_CONFIG_CHANGED + // invalidation 27ms after the rebaseline had already absorbed that very save. + val executor = GatedExecutor() + val events = mutableListOf() + val gradleConfig = "app/build.gradle.kts" + val orchestrator = + LiveReloadOrchestrator( + executor, + ChangeClassifier(), + backgroundScope, + wallClock = { 10_000L }, + fileLastModified = { file -> if (file.path == gradleConfig) 9_900L else 0L }, + ) { events += it } + + orchestrator.onFilesChanged(known(gradleConfig)) + runCurrent() + assertThat(events.filterIsInstance()).hasSize(1) + + orchestrator.onProxyAppRebuildStarted() + // The echo of the very save the rebuild is absorbing: on disk (mtime 9900) before + // the rebuild started (10000), so Gradle read it with the rest of the tree. + orchestrator.onFilesChanged(known(gradleConfig)) + orchestrator.onBaselineReset() + runCurrent() + + assertThat(events.filterIsInstance()).hasSize(1) + assertThat(executor.requests).isEmpty() + } + + @Test + fun `a mid-rebuild batch is split by mtime - the echo absorbed, the newer edit kept`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val gradleConfig = "app/build.gradle.kts" + val mtimes = mapOf(gradleConfig to 9_900L, srcA to 10_500L) + val orchestrator = + LiveReloadOrchestrator( + executor, + ChangeClassifier(), + backgroundScope, + wallClock = { 10_000L }, + fileLastModified = { file -> mtimes[file.path] ?: 0L }, + ) { events += it } + + orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml")) + runCurrent() + orchestrator.onProxyAppRebuildStarted() + // One batch: the config save's echo plus a real edit made while Gradle runs. + orchestrator.onFilesChanged(known(gradleConfig, srcA)) + orchestrator.onBaselineReset() + runCurrent() + + // Only the newer edit survives to a quick build; the echo went with the rebuild. + assertThat(executor.requests).hasSize(1) + assertThat(executor.requests[0].changes).isEqualTo(known(srcA)) + assertThat(events.filterIsInstance()).hasSize(1) + } + + @Test + fun `a fully absorbed echo does not stamp the queue clock`() = + runTest { + // A batch the rebuild absorbed queued nothing, so the next real save's t0 must be + // its own arrival - not the echo's, which would charge it the whole rebuild gap. + var nowMs = 1_000L + val executor = GatedExecutor() + val gradleConfig = "app/build.gradle.kts" + val orchestrator = + LiveReloadOrchestrator( + executor, + ChangeClassifier(), + backgroundScope, + now = { nowMs }, + wallClock = { 10_000L }, + fileLastModified = { file -> if (file.path == gradleConfig) 9_900L else 0L }, + ) {} + + orchestrator.onFilesChanged(known(gradleConfig)) + runCurrent() + orchestrator.onProxyAppRebuildStarted() + nowMs = 2_000L + orchestrator.onFilesChanged(known(gradleConfig)) // echo, fully absorbed + orchestrator.onBaselineReset() + runCurrent() + assertThat(executor.requests).isEmpty() + + nowMs = 60_000L + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + + assertThat(executor.requests.single().triggeredAtMillis).isEqualTo(60_000L) + } + + @Test + fun `a failed rebuild restores absorbed echoes to pending along with the held set`() = + runTest { + // Nothing was absorbed after all - the echo folded into awaitingAbsorption must come + // back with the rest, or a failed rebuild silently loses the echoed save. + val executor = GatedExecutor() + val mtimes = mapOf(srcB to 9_900L, srcC to 10_500L) + val orchestrator = + LiveReloadOrchestrator( + executor, + ChangeClassifier(), + backgroundScope, + wallClock = { 10_000L }, + fileLastModified = { file -> mtimes[file.path] ?: 0L }, + ) {} + + orchestrator.onFilesChanged(known(srcA)) // starts build #1 + runCurrent() + orchestrator.onProxyAppRebuildStarted() // absorbs the in-flight batch {srcA} + // srcB (echo, absorbed) and srcC (newer, stays pending) in one mid-rebuild batch. + orchestrator.onFilesChanged(known(srcB, srcC)) + orchestrator.onProxyAppRebuildFailed() + runCurrent() + assertThat(executor.requests).hasSize(1) // the failure starts nothing on its own + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].changes).isEqualTo(known(srcA, srcB, srcC)) + } + + @Test + fun `a save echo stamped at exactly the rebuild's start millisecond is absorbed - the boundary is inclusive`() = + runTest { + // The F4 case verbatim: coarse mtimes regularly stamp the tap's save echo with the + // same millisecond the rebuild started on. An exclusive upper bound (`until` + // semantics) would strand it in pending and re-open F4 as a spurious invalidation. + val executor = GatedExecutor() + val events = mutableListOf() + val gradleConfig = "app/build.gradle.kts" + val orchestrator = + LiveReloadOrchestrator( + executor, + ChangeClassifier(), + backgroundScope, + wallClock = { 10_000L }, + fileLastModified = { 10_000L }, + ) { events += it } + + orchestrator.onFilesChanged(known(gradleConfig)) + runCurrent() + assertThat(events.filterIsInstance()).hasSize(1) + + orchestrator.onProxyAppRebuildStarted() + orchestrator.onFilesChanged(known(gradleConfig)) + orchestrator.onBaselineReset() + runCurrent() + + assertThat(events.filterIsInstance()).hasSize(1) + assertThat(executor.requests).isEmpty() + } + + @Test + fun `a mid-rebuild file with no readable mtime stays pending - nothing proves it predates the read`() = + runTest { + // 0 means missing or unreadable, not ancient: absorbing it would drop a real + // mid-rebuild edit whose mtime simply could not be read. + val executor = GatedExecutor() + val orchestrator = + LiveReloadOrchestrator( + executor, + ChangeClassifier(), + backgroundScope, + wallClock = { 10_000L }, + fileLastModified = { 0L }, + ) {} + + orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml")) + runCurrent() + orchestrator.onProxyAppRebuildStarted() + orchestrator.onFilesChanged(known(srcA)) + orchestrator.onBaselineReset() + runCurrent() + + assertThat(executor.requests).hasSize(1) + assertThat(executor.requests[0].changes).isEqualTo(known(srcA)) + } + + @Test + fun `a mid-rebuild removal stays pending - no mtime is left to date it`() = + runTest { + val executor = GatedExecutor() + val removal = ChangedFiles.Known(emptySet(), setOf(File(srcB))) + val orchestrator = + LiveReloadOrchestrator( + executor, + ChangeClassifier(), + backgroundScope, + wallClock = { 10_000L }, + // Every path reads as pre-start, so a split that dated removals by mtime + // WOULD absorb this one; a deleted file must not be dated at all. + fileLastModified = { 9_900L }, + ) {} + + orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml")) + runCurrent() + orchestrator.onProxyAppRebuildStarted() + orchestrator.onFilesChanged(removal) + orchestrator.onBaselineReset() + runCurrent() + + // The deletion still needs its own build once the fresh baseline lands. + assertThat(executor.requests).hasSize(1) + assertThat(executor.requests[0].changes).isEqualTo(removal) + } + + @Test + fun `a mid-rebuild Unknown batch passes through the echo split un-absorbed`() = + runTest { + // Unknown enumerates nothing, so nothing can prove any of it predates the rebuild's + // read; absorbing it wholesale would swallow "recompile everything from current + // disk" into a rebuild that only read what existed at its start. + val executor = GatedExecutor() + val orchestrator = + LiveReloadOrchestrator( + executor, + ChangeClassifier(), + backgroundScope, + wallClock = { 10_000L }, + fileLastModified = { 9_900L }, + ) {} + + orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml")) + runCurrent() + orchestrator.onProxyAppRebuildStarted() + orchestrator.onFilesChanged(ChangedFiles.Unknown) + orchestrator.onBaselineReset() + runCurrent() + + assertThat(executor.requests).hasSize(1) + assertThat(executor.requests[0].changes).isEqualTo(ChangedFiles.Unknown) + } + + @Test + fun `failed proxy app rebuild returns the held batch to pending and re-reports on next save`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml")) + runCurrent() + assertThat(events.filterIsInstance()).hasSize(1) + + orchestrator.onProxyAppRebuildStarted() + orchestrator.onProxyAppRebuildFailed() + runCurrent() + // Nothing was absorbed; no event yet (re-reporting here would loop the fallback). + assertThat(events.filterIsInstance()).hasSize(1) + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + // Manifest is still pending -> invalidation is re-reported, no quick build runs. + assertThat(events.filterIsInstance()).hasSize(2) + assertThat(executor.requests).isEmpty() + } + + @Test + fun `baseline reset without started falls back to dropping pending`() = + runTest { + // Protocol-violation compatibility path: reset with no started call drops all. + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml")) + runCurrent() + orchestrator.onBaselineReset() + runCurrent() + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + assertThat(executor.requests).hasSize(1) + assertThat(executor.requests[0].changes).isEqualTo(known(srcA)) + } + + @Test + fun `result of a superseded build is discarded, never rendered`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + events.clear() + + // A full Gradle proxy app rebuild reset the session's baseline while build #1 was in flight. + orchestrator.onProxyAppRebuildStarted() + orchestrator.onBaselineReset() + executor.finish(0, success(generation = 7)) + runCurrent() + + // The late result must produce no events - its diagnostics/success are stale. + assertThat(events).isEmpty() + } + + // Dropping the in-flight REFERENCE is not enough: an orphaned coroutine runs on and deploys + // a payload compiled against the pre-rebuild baseline into an app Gradle is reinstalling. + @Test + fun `a proxy app rebuild cancels the build it supersedes instead of orphaning its deploy`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + assertThat(executor.requests).hasSize(1) + events.clear() + + orchestrator.onProxyAppRebuildStarted() + runCurrent() + + // The build coroutine is dead, not merely unreferenced. + assertThat(executor.cancellations).isEqualTo(1) + + // And it stays dead: the compile finishing cannot push the stale payload out. + executor.finish(0, success(generation = 7)) + runCurrent() + assertThat(executor.deploys).isEqualTo(0) + assertThat(events).isEmpty() + } + + @Test + fun `a baseline reset with no rebuild started cancels the build it orphans`() = + runTest { + // Same defect on the protocol-violation fallback: it drops the pending set, so it must + // not leave a build running against a baseline that just moved under it. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + assertThat(executor.requests).hasSize(1) + events.clear() + + orchestrator.onBaselineReset() + runCurrent() + + assertThat(executor.cancellations).isEqualTo(1) + + executor.finish(0, success(generation = 7)) + runCurrent() + assertThat(executor.deploys).isEqualTo(0) + assertThat(events).isEmpty() + } + + // pendingUserInitiated must not latch across a rebaseline: the tap it records is answered by + // the Gradle build that absorbs its changes, and a surviving flag would report the next + // unrelated automatic save as the user's own ask, pulling them out of the editor into the + // proxy app. + @Test + fun `a tap absorbed by a proxy app rebuild does not tag the next automatic save as the user's ask`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + // A manifest edit parks the session on an invalidation, so the tap lands with real work + // pending and no build to consume it - which is what arms the flag. + orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml")) + runCurrent() + assertThat(orchestrator.onLiveReloadRequested(userInitiated = true)) + .isEqualTo(LiveReloadRequestOutcome.AWAITS_DEPLOY) + runCurrent() + assertThat(executor.requests).isEmpty() + + // Gradle absorbs the manifest edit; that build is the answer to the tap. + orchestrator.onProxyAppRebuildStarted() + orchestrator.onBaselineReset() + runCurrent() + assertThat(executor.requests).isEmpty() + + // A plain autosave, much later. The user asked for nothing here. + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + executor.finish(0, success(generation = 1)) + runCurrent() + + assertThat(executor.requests.single().userInitiated).isFalse() + val succeeded = events.filterIsInstance().single() + assertThat(succeeded.userInitiated).isFalse() + } + + @Test + fun `a tap dropped by the no-rebuild-started fallback does not tag the next save either`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml")) + runCurrent() + assertThat(orchestrator.onLiveReloadRequested(userInitiated = true)) + .isEqualTo(LiveReloadRequestOutcome.AWAITS_DEPLOY) + // Drops the pending set, and with it the tap that asked about it. + orchestrator.onBaselineReset() + runCurrent() + assertThat(executor.requests).isEmpty() + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + executor.finish(0, success(generation = 1)) + runCurrent() + + assertThat(executor.requests.single().userInitiated).isFalse() + val succeeded = events.filterIsInstance().single() + assertThat(succeeded.userInitiated).isFalse() + } + + @Test + fun `the reconnect catch-up with nothing changed still executes a forced redeploy build`() = + runTest { + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onLiveReloadRequested(userInitiated = false) + runCurrent() + + assertThat(executor.requests).hasSize(1) + assertThat(executor.requests[0].forced).isTrue() + assertThat(executor.requests[0].route).isEqualTo(BuildRoute.NoOp) + assertThat(executor.requests[0].changes.isEmpty).isTrue() + } + + @Test + fun `a reconnect catch-up during an in-flight build runs a forced follow-up after success`() = + runTest { + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + orchestrator.onLiveReloadRequested(userInitiated = false) + runCurrent() + assertThat(executor.requests).hasSize(1) + + executor.finish(0, success(generation = 1)) + runCurrent() + + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].forced).isTrue() + } + + @Test + fun `a failed forced catch-up build retries forced`() = + runTest { + // The forced flag is re-armed by a failure - the app is still behind, so the retry + // must still redeploy even if the retrying save's own route would not. + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onLiveReloadRequested(userInitiated = false) + runCurrent() + executor.finish(0, deployFailure()) + runCurrent() + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].forced).isTrue() + } + + @Test + fun `an executor that throws is treated as an infrastructure failure and the batch survives`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + executor.throwOnNext = IllegalStateException("daemon socket closed") + orchestrator.onFilesChanged(known(srcA, srcB)) + runCurrent() + + val failure = events.filterIsInstance().single() + assertThat(failure.outcome).isInstanceOf(BuildOutcome.InfrastructureFailure::class.java) + + // The batch is preserved: the next save rebuilds everything. + orchestrator.onFilesChanged(known(srcC)) + runCurrent() + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].changes).isEqualTo(known(srcA, srcB, srcC)) + } + + @Test + fun `crash recovery - priming with unknown yields one slow-but-correct first build`() = + runTest { + // After a CoGo restart the watcher history is gone; the session manager primes + // the fresh orchestrator with Unknown. First build is full, nothing is lost. + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(ChangedFiles.Unknown) + runCurrent() + + assertThat(executor.requests).hasSize(1) + assertThat(executor.requests[0].route).isEqualTo(BuildRoute.CodeAndResources) + + executor.finish(0, success(generation = 42)) + runCurrent() + + // Back to normal incremental behavior afterwards. + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].changes).isEqualTo(known(srcA)) + } + + @Test + fun `success and failure events carry the outcome for the status surface`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + executor.finish(0, success(generation = 3)) + runCurrent() + + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + executor.finish(1, compileError()) + runCurrent() + + val succeeded = events.filterIsInstance().single() + assertThat(succeeded.result.generation).isEqualTo(3) + + val failed = events.filterIsInstance().single() + val error = failed.outcome as BuildOutcome.CompileError + assertThat(error.diagnostics.single().file).isEqualTo("B.kt") + assertThat(error.diagnostics.single().line).isEqualTo(7) + } + + @Test + fun `onBaselineUntrusted marks the baseline dirty without starting a build`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onBaselineUntrusted() + runCurrent() + + // Deferred refresh: no build, no events, until the next save or tap. + assertThat(executor.requests).isEmpty() + assertThat(events).isEmpty() + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + + // The next build recompiles everything from current disk. + assertThat(executor.requests.single().changes).isEqualTo(ChangedFiles.Unknown) + assertThat(executor.requests.single().route).isEqualTo(BuildRoute.CodeAndResources) + } + + @Test + fun `onBaselineUntrusted during an in-flight build coalesces the refresh into the follow-up`() = + runTest { + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + orchestrator.onBaselineUntrusted() + runCurrent() + + // The running compile is never cancelled; the mark waits. + assertThat(executor.requests).hasSize(1) + assertThat(executor.cancellations).isEqualTo(0) + + executor.finish(0, success(generation = 1)) + runCurrent() + + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].changes).isEqualTo(ChangedFiles.Unknown) + } + + @Test + fun `warm-compile request with nothing pending starts a warm-compile build compiling everything`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onWarmCompileRequested() + runCurrent() + + assertThat(executor.requests).hasSize(1) + assertThat(executor.requests[0].route).isEqualTo(BuildRoute.WarmCompile) + assertThat(executor.requests[0].changes).isEqualTo(ChangedFiles.Unknown) + assertThat(executor.requests[0].forced).isFalse() + assertThat(events).containsExactly( + // Unknown, not Known.EMPTY - a warm compile covers every source, so metrics must + // not read this as "0 files changed". + OrchestratorEvent.BuildStarted(1L, BuildRoute.WarmCompile, ChangedFiles.Unknown), + ) + + executor.finish(0, success(generation = 0)) + runCurrent() + val succeeded = events.filterIsInstance().single() + assertThat(succeeded.route).isEqualTo(BuildRoute.WarmCompile) + } + + @Test + fun `a save that lands before the warm compile starts drops it - the real build warms implicitly`() = + runTest { + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + // The save's build is in flight; the warm-compile request arrives late. + orchestrator.onWarmCompileRequested() + executor.finish(0, success(generation = 1)) + runCurrent() + + // No second build: the save's build already compiled the full source set + // (daemon first-build contract), so the warm compile would be pure waste. + assertThat(executor.requests).hasSize(1) + assertThat(executor.requests[0].route).isEqualTo(BuildRoute.CodeOnly) + } + + @Test + fun `a save landing mid-warm-compile queues and builds right after it finishes`() = + runTest { + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onWarmCompileRequested() + runCurrent() + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + + // Single-flight: the save waits for the warm compile, never overlaps it. + assertThat(executor.requests).hasSize(1) + assertThat(executor.cancellations).isEqualTo(0) + + executor.finish(0, success(generation = 0)) + runCurrent() + + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].route).isEqualTo(BuildRoute.CodeOnly) + assertThat(executor.requests[1].changes).isEqualTo(known(srcA)) + } + + @Test + fun `daemon replacement with nothing pending re-warms via a deploy-nothing warm compile`() = + runTest { + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onDaemonReplaced() + runCurrent() + + assertThat(executor.requests).hasSize(1) + assertThat(executor.requests[0].route).isEqualTo(BuildRoute.WarmCompile) + assertThat(executor.requests[0].changes).isEqualTo(ChangedFiles.Unknown) + } + + @Test + fun `daemon replacement with pending saves marks the baseline dirty and deploys`() = + runTest { + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + // Save lands while the daemon is dead (watcher outlives it), then the respawn. + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + executor.finish(0, compileError()) // dead daemon's build failed; batch unioned back + runCurrent() + orchestrator.onDaemonReplaced() + runCurrent() + + // A REAL deploying build over everything, not a warm compile. + val replay = executor.requests.last() + assertThat(replay.route).isEqualTo(BuildRoute.CodeAndResources) + assertThat(replay.changes).isEqualTo(ChangedFiles.Unknown) + } + + @Test + fun `daemon replacement mid-build unions Unknown into pending - the build's own failure, not a supersession, starts the follow-up`() = + runTest { + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + // Daemon died mid-build; respawn lands BEFORE the failure result does. The + // in-flight build is NOT superseded here (its buildId stays inFlight) - it + // still owns its own failure/follow-up below; onDaemonReplaced only marks the + // pending batch Unknown for whatever build eventually follows. + orchestrator.onDaemonReplaced() + runCurrent() + assertThat(executor.requests).hasSize(1) + + executor.finish(0, BuildOutcome.InfrastructureFailure("daemon died", daemonDied = true)) + runCurrent() + + // The follow-up carries the batch + the Unknown mark - full recompile, deploys. + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].changes).isEqualTo(ChangedFiles.Unknown) + assertThat(executor.requests[1].route).isEqualTo(BuildRoute.CodeAndResources) + } + + @Test + fun `a failed warm compile leaves nothing pending and does not auto-retry`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onWarmCompileRequested() + runCurrent() + executor.finish(0, compileError()) + runCurrent() + + // No retry loop for a background warm-up... + assertThat(executor.requests).hasSize(1) + val failed = events.filterIsInstance().single() + assertThat(failed.route).isEqualTo(BuildRoute.WarmCompile) + + // ...and the next real save builds exactly its own batch (nothing leaked in). + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].changes).isEqualTo(known(srcB)) + } + + @Test + fun `a warm compile's failure does not prime relinkStuck for the first real failure the user sees`() = + runTest { + // A warm-compile failure is invisible to the user (the session manager never surfaces + // it), so it must not count as the first of the repeat pair that flags a stuck + // relink - the user would then be told a single resource typo is blocking every build. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onWarmCompileRequested() + runCurrent() + // A real save lands WHILE the warm compile runs - its build starts automatically + // as this build's auto-follow-up once the warm compile's own result lands. + orchestrator.onFilesChanged(known(resLayout)) + runCurrent() + executor.finish(0, resourceError()) // the warm compile's (invisible) failure + runCurrent() + assertThat(executor.requests).hasSize(2) + executor.finish(1, resourceError()) // identical diagnostics to the warm compile's failure + runCurrent() + + val realFailure = events.filterIsInstance().single { it.route != BuildRoute.WarmCompile } + assertThat(realFailure.relinkStuck).isFalse() + } + + // Review gap (2026-07-26 #69): a proxy app rebuild landing mid-warm-compile supersedes it - the + // warm compile's late result must be discarded, and it must NOT re-queue after the reset + // (the proxy app rebuild's own Gradle build just recompiled the world). + @Test + fun `a warm compile superseded by a proxy app rebuild is discarded and does not restart after the reset`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onWarmCompileRequested() + runCurrent() + assertThat(executor.requests.single().route).isEqualTo(BuildRoute.WarmCompile) + + // A gradle/manifest edit forced a proxy app rebuild while the warm compile runs. + orchestrator.onProxyAppRebuildStarted() + events.clear() + executor.finish(0, success(generation = 0)) + runCurrent() + // The superseded warm compile's result is discarded: no Succeeded/Failed escapes + // (a WarmCompileFinished here would flip the session out of its proxy-app-rebuild flow). + assertThat(events).isEmpty() + + orchestrator.onBaselineReset() + runCurrent() + // Nothing pending, and the dead warm compile was not resurrected. + assertThat(executor.requests).hasSize(1) + assertThat(events).isEmpty() + + // The session then builds normally again, with exactly the new batch. + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].changes).isEqualTo(known(srcA)) + assertThat(executor.requests[1].route).isEqualTo(BuildRoute.CodeOnly) + } + + // Bryan's button spec: the trigger SOURCE has to survive all the way to the deploy, and a + // stop has to abandon a build without losing its edits. + + @Test + fun `a tap with pending work reports that its answer is the deploy, and tags that build`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + // A save landed but its build has not started yet (mid-rebuild absorption is the + // real-world shape); the tap coalesces into it and must wait for the deploy. + orchestrator.onProxyAppRebuildStarted() + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + assertThat(executor.requests).isEmpty() + + val outcome = orchestrator.onLiveReloadRequested(userInitiated = true) + orchestrator.onBaselineReset() + runCurrent() + executor.finish(0, success(generation = 2)) + runCurrent() + + assertThat(outcome).isEqualTo(LiveReloadRequestOutcome.AWAITS_DEPLOY) + val succeeded = events.filterIsInstance().single() + assertThat(succeeded.userInitiated).isTrue() + } + + @Test + fun `a clean tap with nothing pending builds nothing and tells the caller to switch`() = + runTest { + // The F7 root fix's do-nothing half: the deployed app is current, so answering the + // tap costs no build at all - where the old forced NoOp recompiled a whole module + // to redeploy identical bytes. + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + val outcome = orchestrator.onLiveReloadRequested(userInitiated = true, expectChanges = false) + runCurrent() + + assertThat(outcome).isEqualTo(LiveReloadRequestOutcome.SWITCH_NOW) + assertThat(executor.requests).isEmpty() + + // And nothing lingers: the next save's build is a plain routed one, not forced and + // not the user's ask. + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + assertThat(executor.requests.single().forced).isFalse() + assertThat(executor.requests.single().userInitiated).isFalse() + } + + @Test + fun `a tap that wrote something arms on the incoming batch instead of forcing a build`() = + runTest { + // The F7 root fix's other half: the tap's save-all wrote files whose batch is still + // inside the coalescer window. The batch, not the tap, drives the one build - so it + // is routed off the real changed-set instead of a forced blind NoOp. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + val outcome = orchestrator.onLiveReloadRequested(userInitiated = true, expectChanges = true) + runCurrent() + assertThat(outcome).isEqualTo(LiveReloadRequestOutcome.AWAITS_CHANGES) + assertThat(executor.requests).isEmpty() + + // The save-all's batch lands; its build carries the tap's ask. + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + val request = executor.requests.single() + assertThat(request.route).isEqualTo(BuildRoute.CodeOnly) + assertThat(request.forced).isFalse() + assertThat(request.userInitiated).isTrue() + + executor.finish(0, success(generation = 1)) + runCurrent() + val succeeded = events.filterIsInstance().single() + assertThat(succeeded.userInitiated).isTrue() + + // The batch already answered the tap, so the deadline fallback must find nothing. + assertThat(orchestrator.consumeUnansweredTap()).isFalse() + } + + @Test + fun `an armed tap whose batch never comes is consumed by the deadline exactly once`() = + runTest { + // The .md-save edge: every written file was watcher-irrelevant, so no batch ever + // arrives and the deadline is the only thing left to answer the tap. + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onLiveReloadRequested(userInitiated = true, expectChanges = true) + runCurrent() + + assertThat(orchestrator.consumeUnansweredTap()).isTrue() + // Exactly once: a second fallback (two taps racing) must not switch again. + assertThat(orchestrator.consumeUnansweredTap()).isFalse() + + // The expired tap leaves nothing behind: a later save's build is not the user's ask. + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + assertThat(executor.requests.single().forced).isFalse() + assertThat(executor.requests.single().userInitiated).isFalse() + } + + @Test + fun `a build a save triggered is never tagged as user-initiated`() = + runTest { + // Behaviour 3, at the source: nothing about a watcher batch may set the flag that + // pulls the user out of the editor. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + executor.finish(0, success(generation = 1)) + runCurrent() + + val succeeded = events.filterIsInstance().single() + assertThat(succeeded.userInitiated).isFalse() + } + + @Test + fun `a non-user request must not tag its build, even though it is forced`() = + runTest { + // The reconnect catch-up is forced exactly like a tap, which is why "forced" is not + // a usable stand-in for "the user asked". + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(srcA)) + orchestrator.onLiveReloadRequested(userInitiated = false) + runCurrent() + executor.finish(0, success(generation = 1)) + runCurrent() + + val succeeded = events.filterIsInstance().single() + assertThat(succeeded.result.generation).isEqualTo(1) + assertThat(succeeded.userInitiated).isFalse() + } + + @Test + fun `a failed user-initiated build does not re-tag the save that retries it`() = + runTest { + // The tap was already answered - with the compile error. The save that fixes the + // code is not a new ask, so it must not yank the user out of the editor. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + // Hold the batch so the tap lands BEFORE the build starts and really tags it. + orchestrator.onProxyAppRebuildStarted() + orchestrator.onFilesChanged(known(srcA)) + orchestrator.onLiveReloadRequested(userInitiated = true) + orchestrator.onBaselineReset() + runCurrent() + assertThat(executor.requests).hasSize(1) + + // A save lands mid-build so the failure triggers an immediate follow-up. + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + executor.finish(0, compileError()) + runCurrent() + assertThat(executor.requests).hasSize(2) + executor.finish(1, success(generation = 1)) + runCurrent() + + val succeeded = events.filterIsInstance().single() + assertThat(succeeded.userInitiated).isFalse() + // A tap no longer forces anything, so there is no forced flag to survive either; + // forced-survives-failure is pinned on the reconnect path, the one caller left + // that sets it (see `a failed forced catch-up build retries forced`). + assertThat(executor.requests[1].forced).isFalse() + } + + @Test + fun `marking an in-flight build carries the ask without starting a second build`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + + assertThat(orchestrator.markInFlightUserInitiated()).isTrue() + executor.finish(0, success(generation = 1)) + runCurrent() + + assertThat(executor.requests).hasSize(1) + val succeeded = events.filterIsInstance().single() + assertThat(succeeded.userInitiated).isTrue() + // The request left before the tap arrived, so unless the executor is told + // separately this build's deploy still refuses to open a closed app - and the tap + // silently does nothing. + assertThat(executor.promotions).isEqualTo(1) + } + + @Test + fun `a save's build is not user-initiated, so its deploy may not take the screen`() = + runTest { + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + + assertThat(executor.requests.single().userInitiated).isFalse() + assertThat(executor.promotions).isEqualTo(0) + } + + @Test + fun `a tap's build is user-initiated, so its deploy may open the app`() = + runTest { + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + // A tap only arms the flag when there is real work to wait for; a tap with nothing + // pending is answered by the caller itself. So park a build in flight, save again + // so the next batch is pending, then tap. + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + orchestrator.onFilesChanged(known(srcB)) + orchestrator.onLiveReloadRequested(userInitiated = true) + runCurrent() + + executor.finish(0, success(generation = 1)) + runCurrent() + + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].userInitiated).isTrue() + // The tap arms the NEXT request only: the build already in flight left before the + // tap and stays untagged, or its deploy would take the screen for work nobody asked + // about. No promotion either - that is markInFlightUserInitiated's job, not a tap's. + assertThat(executor.requests[0].userInitiated).isFalse() + assertThat(executor.promotions).isEqualTo(0) + } + + @Test + fun `a reconnect catch-up is never user-initiated, so a stale reconnect cannot steal the screen`() = + runTest { + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(known(srcA)) + orchestrator.onLiveReloadRequested(userInitiated = false) + runCurrent() + + assertThat(executor.requests.single().userInitiated).isFalse() + } + + @Test + fun `marking refuses when there is no build to carry the ask`() = + runTest { + // Nothing in flight, and a warm compile in flight, both have to say no: a warm compile deploys + // nothing, so it can never be a tap's answer. The caller then falls back to a real + // request instead of dropping the tap. + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + assertThat(orchestrator.markInFlightUserInitiated()).isFalse() + + orchestrator.onWarmCompileRequested() + runCurrent() + assertThat(executor.requests.single().route).isEqualTo(BuildRoute.WarmCompile) + assertThat(orchestrator.markInFlightUserInitiated()).isFalse() + // Refusing has to be total: a promotion that escaped ahead of the guard would tag + // the warm compile - or whatever starts next - with an ask it cannot answer. + assertThat(executor.promotions).isEqualTo(0) + } + + @Test + fun `a cancelled build reports nothing and returns its batch to pending`() = + runTest { + // Behaviour 5, and the never-lose-pending invariant it must not break: the stopped + // edit is still owed a build, so the next save carries it too. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + + assertThat(orchestrator.onCancelRequested()).isTrue() + runCurrent() + + // The abandoned build produced no outcome event at all: not a success, and not a + // failure either - a cancellation is neither. + assertThat(events.filterIsInstance()).isEmpty() + assertThat(events.filterIsInstance()).isEmpty() + assertThat(executor.cancellations).isEqualTo(1) + + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].changes).isEqualTo(known(srcA, srcB)) + } + + @Test + fun `a cancelled tap is withdrawn - the rebuild is not forced`() = + runTest { + // The user asked, then unasked. A forced flag surviving the cancel would make the + // next save redeploy at a fresh generation as if the tap still stood. + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(known(srcA)) + orchestrator.onLiveReloadRequested(userInitiated = true) + runCurrent() + orchestrator.onCancelRequested() + runCurrent() + + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + assertThat(executor.requests).hasSize(2) + assertThat(executor.requests[1].forced).isFalse() + } + + @Test + fun `cancelling refuses when nothing is running, and never touches the warm compile`() = + runTest { + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + assertThat(orchestrator.onCancelRequested()).isFalse() + + orchestrator.onWarmCompileRequested() + runCurrent() + assertThat(orchestrator.onCancelRequested()).isFalse() + // The warm compile keeps running: it is the daemon warm-up the next real save needs. + assertThat(executor.cancellations).isEqualTo(0) + executor.finish(0, success(generation = 0)) + runCurrent() + } + + @Test + fun `a build that is not stopped still deploys after a cancel of an earlier one`() = + runTest { + // The cancel must not wedge the orchestrator: clearing inFlight is what lets the + // next build start at all. Without it every later build would be suspended forever. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + orchestrator.onCancelRequested() + runCurrent() + + orchestrator.onFilesChanged(known(srcC)) + runCurrent() + executor.finish(1, success(generation = 1)) + runCurrent() + + assertThat(events.filterIsInstance()).hasSize(1) + } + + @Test + fun `a relink failure that repeats identically escalates to a proxy app rebuild`() = + runTest { + // The stuck-relink gap: the failed batch returns to pending, so the broken resource + // is dragged into every later build and re-fails - including builds whose own edit + // was pure code. Nothing on the live reload path can clear it. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(resLayout)) + runCurrent() + executor.finish(0, relinkFailure()) + runCurrent() + // One failure is not evidence: it may have been transient. + assertThat(events.filterIsInstance()).isEmpty() + + // A later code save drags the still-pending resource back in and fails identically. + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + assertThat(executor.requests[1].route).isEqualTo(BuildRoute.CodeAndResources) + executor.finish(1, relinkFailure()) + runCurrent() + + assertThat(events.filterIsInstance()) + .containsExactly( + OrchestratorEvent.InvalidationRequired(InvalidationReason.RELOAD_PIPELINE_FAILED), + ) + // The failure is still reported: the fallback is visible, not a silent swallow. + assertThat(events.filterIsInstance()).hasSize(2) + // Nothing else was launched to be superseded by the rebuild. + assertThat(executor.requests).hasSize(2) + + // Never-stale: the whole batch is still pending, so the rebuild absorbs it - and a + // save landing before the rebuild starts still carries both earlier edits. + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + assertThat(executor.requests[2].changes).isEqualTo(known(resLayout, srcA, srcB)) + } + + @Test + fun `a proxy app rebuild that fails is not requested again - no rebuild-fail-rebuild loop`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(resLayout)) + runCurrent() + executor.finish(0, relinkFailure()) + runCurrent() + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + executor.finish(1, relinkFailure()) + runCurrent() + assertThat(events.filterIsInstance()).hasSize(1) + + // The rebuild ran and failed; the batch comes back and quick builds resume. + orchestrator.onProxyAppRebuildStarted() + orchestrator.onProxyAppRebuildFailed() + runCurrent() + + // Two more identical failures must NOT ask for another rebuild. + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + executor.finish(2, relinkFailure()) + runCurrent() + orchestrator.onFilesChanged(known(srcC)) + runCurrent() + executor.finish(3, relinkFailure()) + runCurrent() + + assertThat(events.filterIsInstance()).hasSize(1) + } + + @Test + fun `a successful rebuild re-arms the escalation`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(resLayout)) + runCurrent() + executor.finish(0, relinkFailure()) + runCurrent() + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + executor.finish(1, relinkFailure()) + runCurrent() + orchestrator.onProxyAppRebuildStarted() + orchestrator.onBaselineReset() + runCurrent() + + // A fresh baseline, and the pipeline breaks again: that deserves its own rebuild. + orchestrator.onFilesChanged(known(resLayout)) + runCurrent() + executor.finish(2, relinkFailure()) + runCurrent() + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + executor.finish(3, relinkFailure()) + runCurrent() + + assertThat(events.filterIsInstance()).hasSize(2) + } + + @Test + fun `a repeated compile error never escalates - it is the user's code, not the pipeline`() = + runTest { + // Escalating here would run a ~200s Gradle build that rejects the same code, and a + // failed proxy app rebuild drops the session to Idle - worse than the compile error. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(resLayout)) + runCurrent() + executor.finish(0, compileError()) + runCurrent() + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + executor.finish(1, compileError()) + runCurrent() + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + executor.finish(2, compileError()) + runCurrent() + + assertThat(events.filterIsInstance()).isEmpty() + } + + @Test + fun `a daemon death does not escalate - it has its own respawn recovery`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + val died = BuildOutcome.InfrastructureFailure("daemon exited", daemonDied = true) + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + executor.finish(0, died) + runCurrent() + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + executor.finish(1, died) + runCurrent() + + assertThat(events.filterIsInstance()).isEmpty() + } + + @Test + fun `two different pipeline failures do not escalate - only an identical repeat is evidence`() = + runTest { + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(resLayout)) + runCurrent() + executor.finish(0, BuildOutcome.InfrastructureFailure("aapt2 link: broken pipe")) + runCurrent() + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + executor.finish(1, BuildOutcome.InfrastructureFailure("scratch dir is full")) + runCurrent() + + assertThat(events.filterIsInstance()).isEmpty() + } + + @Test + fun `a failed warm compile never escalates`() = + runTest { + // A warm compile's failure is not user-visible, so it must not drag the user into a + // full Gradle build either. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onWarmCompileRequested() + runCurrent() + executor.finish(0, relinkFailure()) + runCurrent() + orchestrator.onWarmCompileRequested() + runCurrent() + executor.finish(1, relinkFailure()) + runCurrent() + + assertThat(events.filterIsInstance()).isEmpty() + } + + @Test + fun `a repeating aapt2 rejection is flagged as blocking every build`() = + runTest { + // The other half of the stuck-relink gap. aapt2 links the whole res/ tree from disk, + // not the changed set, so an unlinkable resource fails every later build whatever the + // user saves next - and the one they cannot fix by editing (a reference the proxy + // app build's resource snapshot lacks) leaves the session dead with no explanation. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(resLayout)) + runCurrent() + executor.finish(0, resourceError()) + runCurrent() + // One rejection is an ordinary compile error - the user is looking at the file. + assertThat(events.filterIsInstance().map { it.relinkStuck }) + .containsExactly(false) + + // A pure-code save drags the still-pending resource back in and fails identically. + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + assertThat(executor.requests[1].route).isEqualTo(BuildRoute.CodeAndResources) + executor.finish(1, resourceError()) + runCurrent() + + assertThat(events.filterIsInstance().map { it.relinkStuck }) + .containsExactly(false, true) + // Saying it is all this does: no escalation, so a resource typo never costs a ~200s + // Gradle build and a failed one can never drop the session to Idle. + assertThat(events.filterIsInstance()).isEmpty() + // Never-stale is untouched - the whole batch is still pending. + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + assertThat(executor.requests[2].changes).isEqualTo(known(resLayout, srcA, srcB)) + } + + @Test + fun `a repeating kotlinc error is not flagged as blocking - it names the file being edited`() = + runTest { + // Same shape as the aapt2 case and deliberately not flagged: the error names the file + // the user is working in, so nothing about it is surprising, and there is no variant + // of it that no edit can fix. Flagging it would fire on ordinary mid-typing saves. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + executor.finish(0, compileError()) + runCurrent() + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + executor.finish(1, compileError()) + runCurrent() + orchestrator.onFilesChanged(known(srcC)) + runCurrent() + executor.finish(2, compileError()) + runCurrent() + + assertThat(events.filterIsInstance().map { it.relinkStuck }) + .containsExactly(false, false, false) + } + + @Test + fun `the blocking flag is raised once per streak and re-armed by a success`() = + runTest { + // The message asks the user to do something, so repeating it on every save would + // train them to dismiss it. A success means the resources link again, which makes a + // later stuck relink a genuinely new situation. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(resLayout)) + runCurrent() + executor.finish(0, resourceError()) + runCurrent() + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + executor.finish(1, resourceError()) + runCurrent() + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + executor.finish(2, resourceError()) + runCurrent() + orchestrator.onFilesChanged(known(srcC)) + runCurrent() + executor.finish(3, success(generation = 1)) + runCurrent() + orchestrator.onFilesChanged(known(resLayout)) + runCurrent() + executor.finish(4, resourceError()) + runCurrent() + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + executor.finish(5, resourceError()) + runCurrent() + + assertThat(events.filterIsInstance().map { it.relinkStuck }) + .containsExactly(false, true, false, false, true) + } + + @Test + fun `a second not-connected deploy running is flagged as the proxy app not staying up`() = + runTest { + // The baseline-crash trap: provisioning captured a startup crash, so the app dies + // before it can receive anything. Every save compiles and dexes fine and then has + // nowhere to land, and the failure's own "relaunch to reconnect" restarts the crash. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + executor.finish(0, notConnected()) + runCurrent() + // One is ordinary: the app may simply have been closed, and the deploy relaunches it. + assertThat(events.filterIsInstance().map { it.proxyAppWontStayUp }) + .containsExactly(false) + + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + executor.finish(1, notConnected()) + runCurrent() + + assertThat(events.filterIsInstance().map { it.proxyAppWontStayUp }) + .containsExactly(false, true) + } + + @Test + fun `the not-staying-up report fires once per streak, not on every later save`() = + runTest { + // The message asks the user to restart the session; repeating it on every save would + // train them to dismiss it. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + listOf(srcA, srcB, srcA, srcB).forEachIndexed { i, file -> + orchestrator.onFilesChanged(known(file)) + runCurrent() + executor.finish(i, notConnected()) + runCurrent() + } + + assertThat( + events.filterIsInstance().count { it.proxyAppWontStayUp }, + ).isEqualTo(1) + } + + @Test + fun `a deploy failure that is not the not-connected one never claims it`() = + runTest { + // Typed, not message-matched: only the path that already tried a launch counts. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + repeat(2) { i -> + orchestrator.onFilesChanged(known(if (i == 0) srcA else srcB)) + runCurrent() + executor.finish(i, BuildOutcome.DeployFailure("Proxy app disconnected during deploy")) + runCurrent() + } + + assertThat(events.filterIsInstance().map { it.proxyAppWontStayUp }) + .containsExactly(false, false) + } + + @Test + fun `a pending manifest edit survives a daemon replacement collapsing the set to Unknown`() = + runTest { + // The silent-staleness path: pending + Unknown discards the manifest path, and + // Unknown classifies as the FAST route, so the next build compiles, relinks, deploys + // and reports Success with the manifest change never absorbed. Worse, the + // invalidation was already reported, so the parked state converts to a quiet success. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml")) + runCurrent() + assertThat(events.filterIsInstance()) + .containsExactly(OrchestratorEvent.InvalidationRequired(InvalidationReason.MANIFEST_CHANGED)) + + // A low-memory teardown respawns the daemon before the rebuild runs. + orchestrator.onDaemonReplaced() + runCurrent() + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + + // Still parked: no quick build may run until Gradle absorbs the manifest. + assertThat(executor.requests).isEmpty() + assertThat(events.filterIsInstance()).hasSize(1) + } + + @Test + fun `a pending gradle edit survives an untrusted baseline collapsing the set to Unknown`() = + runTest { + // Same collapse, reached the other way: an external Standard Run hands back and marks + // the baseline untrusted while a build.gradle.kts edit is still pending. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known("app/build.gradle.kts")) + runCurrent() + orchestrator.onBaselineUntrusted() + runCurrent() + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + + assertThat(executor.requests).isEmpty() + assertThat(events.filterIsInstance()) + .containsExactly(OrchestratorEvent.InvalidationRequired(InvalidationReason.GRADLE_CONFIG_CHANGED)) + } + + @Test + fun `an invalidation latched through a collapse is re-reported after a failed proxy app rebuild`() = + runTest { + // The rebuild absorbed nothing, so the manifest edit is still unabsorbed - the next + // save must re-report rather than quietly take the fast path. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml")) + runCurrent() + orchestrator.onDaemonReplaced() + runCurrent() + orchestrator.onProxyAppRebuildStarted() + orchestrator.onProxyAppRebuildFailed() + runCurrent() + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + + assertThat(executor.requests).isEmpty() + assertThat(events.filterIsInstance()).hasSize(2) + } + + @Test + fun `a latched invalidation clears once a proxy app rebuild absorbs it`() = + runTest { + // The latch must not park the session forever: a completed rebaseline releases it. + val executor = GatedExecutor() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) {} + + orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml")) + runCurrent() + orchestrator.onDaemonReplaced() + runCurrent() + orchestrator.onProxyAppRebuildStarted() + orchestrator.onBaselineReset() + runCurrent() + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + + assertThat(executor.requests.single().changes).isEqualTo(known(srcA)) + assertThat(executor.requests.single().route).isEqualTo(BuildRoute.CodeOnly) + } + + @Test + fun `a collapse with no invalidating path pending still takes the fast daemon path`() = + runTest { + // The latch must not turn every Unknown into a Gradle build - that would make an + // external Standard Run's hand-back cost a full rebaseline every time. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + executor.finish(0, compileError()) + runCurrent() + orchestrator.onBaselineUntrusted() + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + + assertThat(executor.requests.last().changes).isEqualTo(ChangedFiles.Unknown) + assertThat(executor.requests.last().route).isEqualTo(BuildRoute.CodeAndResources) + assertThat(events.filterIsInstance()).isEmpty() + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/RealIdInstallTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/RealIdInstallTest.kt new file mode 100644 index 0000000000..1f1c181260 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/RealIdInstallTest.kt @@ -0,0 +1,119 @@ +package org.appdevforall.cotg.quickbuild.domain.reload + +import com.google.common.truth.Truth.assertThat +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage +import org.junit.jupiter.api.Test + +class RealIdInstallTest { + private val ourFactory = RealIdInstall.QUICK_BUILD_APP_COMPONENT_FACTORY + + @Test + fun `isQuickBuildProxyApp is true only for the runtime factory`() { + assertThat(RealIdInstall.isQuickBuildProxyApp(ourFactory)).isTrue() + } + + @Test + fun `isQuickBuildProxyApp is false for a null, empty, or foreign factory`() { + assertThat(RealIdInstall.isQuickBuildProxyApp(null)).isFalse() + assertThat(RealIdInstall.isQuickBuildProxyApp("")).isFalse() + assertThat(RealIdInstall.isQuickBuildProxyApp("androidx.core.app.CoreComponentFactory")).isFalse() + } + + @Test + fun `Quick Build needs no confirm when nothing is installed`() { + assertThat( + RealIdInstall.quickBuildNeedsClobberConfirm( + realAppInstalled = false, + installedFactory = null, + ), + ).isFalse() + } + + @Test + fun `Quick Build needs no confirm when its own proxy app already occupies the slot`() { + assertThat( + RealIdInstall.quickBuildNeedsClobberConfirm( + realAppInstalled = true, + installedFactory = ourFactory, + ), + ).isFalse() + } + + @Test + fun `Quick Build confirms when a different build occupies the slot`() { + // The Standard-Run app (no runtime factory) - or any non-QB occupant. + assertThat( + RealIdInstall.quickBuildNeedsClobberConfirm( + realAppInstalled = true, + installedFactory = null, + ), + ).isTrue() + assertThat( + RealIdInstall.quickBuildNeedsClobberConfirm( + realAppInstalled = true, + installedFactory = "com.example.OtherFactory", + ), + ).isTrue() + } + + @Test + fun `Standard Run confirms only when a Quick Build proxy app occupies the slot`() { + assertThat(RealIdInstall.standardRunNeedsClobberConfirm(ourFactory)).isTrue() + assertThat(RealIdInstall.standardRunNeedsClobberConfirm(null)).isFalse() + assertThat(RealIdInstall.standardRunNeedsClobberConfirm("com.example.OtherFactory")).isFalse() + } + + @Test + fun `signatureRefusal proceeds when nothing is installed`() { + assertThat( + RealIdInstall.signatureRefusal( + realApplicationId = "com.example.app", + realAppInstalled = false, + installedCertSha256 = null, + builtCertSha256 = "abc", + ), + ).isNull() + } + + @Test + fun `signatureRefusal proceeds when the installed cert matches the built cert`() { + assertThat( + RealIdInstall.signatureRefusal( + realApplicationId = "com.example.app", + realAppInstalled = true, + installedCertSha256 = "ABC123", + builtCertSha256 = "abc123", + ), + ).isNull() + } + + @Test + fun `signatureRefusal refuses when the installed cert differs`() { + val message = + RealIdInstall.signatureRefusal( + realApplicationId = "com.example.app", + realAppInstalled = true, + installedCertSha256 = "aaa", + builtCertSha256 = "bbb", + ) + assertThat(message).isEqualTo(QuickBuildMessage.ForeignAppInstalled("com.example.app")) + } + + @Test + fun `signatureRefusal refuses when either cert is unreadable`() { + assertThat( + RealIdInstall.signatureRefusal("com.example.app", true, installedCertSha256 = null, builtCertSha256 = "bbb"), + ).isNotNull() + assertThat( + RealIdInstall.signatureRefusal("com.example.app", true, installedCertSha256 = "aaa", builtCertSha256 = null), + ).isNotNull() + } + + @Test + fun `refusalMessage names the app and the manual way forward`() { + // The applicationId travels as data so the host's copy can name it; the sentence + // around it belongs to the app module's resources, not here. + val message = RealIdInstall.refusalMessage("com.example.app") + assertThat(message).isEqualTo(QuickBuildMessage.ForeignAppInstalled("com.example.app")) + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimelineGroupsTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimelineGroupsTest.kt new file mode 100644 index 0000000000..afff203593 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimelineGroupsTest.kt @@ -0,0 +1,95 @@ +package org.appdevforall.cotg.quickbuild.domain.telemetry + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * The absent-awareness contract of the three timing groups: a group with ANY reported + * field is not empty (the metrics sink keys emission on `isEmpty`), and the walk sum + * treats a half-reported pair as measured. One field at a time, so a regression that + * drops a single field from the emptiness check fails a named case. + */ +class E2eTimelineGroupsTest { + @Test + fun `an all-null StepTimings is empty`() { + assertThat(E2eTimeline.StepTimings().isEmpty()).isTrue() + } + + @Test + fun `each StepTimings field alone makes the group non-empty`() { + val singles = + listOf( + E2eTimeline.StepTimings(kotlinMillis = 1), + E2eTimeline.StepTimings(javaMillis = 1), + E2eTimeline.StepTimings(stripMillis = 1), + E2eTimeline.StepTimings(d8Millis = 1), + E2eTimeline.StepTimings(aapt2CompileMillis = 1), + E2eTimeline.StepTimings(aapt2LinkMillis = 1), + E2eTimeline.StepTimings(preSnapMillis = 1), + E2eTimeline.StepTimings(postSnapMillis = 1), + E2eTimeline.StepTimings(javaAbiSnapMillis = 1), + ) + + singles.forEach { timings -> + assertThat(timings.isEmpty()).isFalse() + } + } + + @Test + fun `walkMillis counts a lone pre-compile snapshot`() { + assertThat(E2eTimeline.StepTimings(preSnapMillis = 120).walkMillis).isEqualTo(120) + } + + @Test + fun `walkMillis counts a lone post-compile snapshot`() { + assertThat(E2eTimeline.StepTimings(postSnapMillis = 130).walkMillis).isEqualTo(130) + } + + @Test + fun `an all-null HostSpans is empty with a zero total`() { + val spans = E2eTimeline.HostSpans() + + assertThat(spans.isEmpty()).isTrue() + assertThat(spans.totalMillis).isEqualTo(0) + } + + @Test + fun `each HostSpans field alone makes the group non-empty and counts toward the total`() { + val singles = + listOf( + E2eTimeline.HostSpans(scanMillis = 7), + E2eTimeline.HostSpans(compileRpcMillis = 7), + E2eTimeline.HostSpans(policyMillis = 7), + E2eTimeline.HostSpans(dexRpcMillis = 7), + E2eTimeline.HostSpans(relinkRpcMillis = 7), + ) + + singles.forEach { spans -> + assertThat(spans.isEmpty()).isFalse() + assertThat(spans.totalMillis).isEqualTo(7) + } + } + + @Test + fun `an all-null BuildCounts is empty`() { + assertThat(E2eTimeline.BuildCounts().isEmpty()).isTrue() + } + + @Test + fun `each BuildCounts field alone makes the group non-empty`() { + val singles = + listOf( + E2eTimeline.BuildCounts(allSources = 1), + E2eTimeline.BuildCounts(kotlinDeclaredChanged = 1), + E2eTimeline.BuildCounts(javaSources = 1), + E2eTimeline.BuildCounts(changedClasses = 1), + E2eTimeline.BuildCounts(classFiles = 1), + E2eTimeline.BuildCounts(classBytes = 1L), + E2eTimeline.BuildCounts(compileOrdinal = 1L), + ) + + singles.forEach { counts -> + assertThat(counts.isEmpty()).isFalse() + } + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimelineTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimelineTest.kt new file mode 100644 index 0000000000..f346b33402 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimelineTest.kt @@ -0,0 +1,204 @@ +package org.appdevforall.cotg.quickbuild.domain.telemetry + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class E2eTimelineTest { + private val sample = E2eTimeline(generation = 7, trigger = 1000, compileDone = 1600, deploySent = 1650, reloadLive = 1720) + + @Test + fun `format renders the grep-stable structured line`() { + assertThat(sample.format()) + .isEqualTo("quickbuild-e2e: gen=7 trigger=1000 compileDone=1600 deploySent=1650 reloadLive=1720") + } + + @Test + fun `format carries the compile ordinal, so a duration can be placed on the warm-up curve`() { + // Without it a 3 s cold build and a 0.9 s warm one read as variance rather than as two + // ends of one curve - which is how three separate readings of this line went wrong. + val second = sample.copy(counts = E2eTimeline.BuildCounts(compileOrdinal = 2)) + + assertThat(second.format()) + .isEqualTo( + "quickbuild-e2e: gen=7 trigger=1000 compileDone=1600 deploySent=1650 " + + "reloadLive=1720 compileOrdinal=2", + ) + } + + @Test + fun `a route that ran no compile omits the ordinal rather than printing a zero`() { + // A resources-only relink never reaches recordCompileSteps, so it has no ordinal. A `0` + // there would parse as a real value and read as the coldest possible build. + val relinkOnly = sample.copy(counts = E2eTimeline.BuildCounts(changedClasses = 0)) + + assertThat(relinkOnly.counts?.compileOrdinal).isNull() + assertThat(relinkOnly.format()).doesNotContain("compileOrdinal") + assertThat(relinkOnly.format()).isEqualTo(sample.format()) + } + + @Test + fun `deltas decompose the loop into compile, stage and reload`() { + assertThat(sample.compileMillis).isEqualTo(600) + assertThat(sample.stageMillis).isEqualTo(50) + assertThat(sample.reloadMillis).isEqualTo(70) + assertThat(sample.totalMillis).isEqualTo(720) + // The parts partition the whole - no gaps, no double-count. + assertThat(sample.compileMillis + sample.stageMillis + sample.reloadMillis) + .isEqualTo(sample.totalMillis) + } + + @Test + fun `a build whose spans cover every step leaves no residual`() { + // The healthy shape, and the one the sora-editor-full device rows showed: the host + // spans partition [trigger, deploySent] and reload covers the rest. + // 40 + 500 + 30 + 80 = 650 = deploySent - trigger; reload = 70. + val timeline = + sample.copy( + spans = + E2eTimeline.HostSpans( + scanMillis = 40, + compileRpcMillis = 500, + policyMillis = 30, + dexRpcMillis = 80, + ), + ) + + assertThat(timeline.accountedMillis).isEqualTo(720) + assertThat(timeline.unaccountedMillis).isEqualTo(0) + } + + @Test + fun `an untimed step shows up as residual rather than inflating a measured span`() { + // The regression this field exists to catch: something inside the build takes 200 ms + // and nothing measures it. Every named span keeps its own honest value; the gap is + // what grows. + val timeline = + sample.copy( + spans = + E2eTimeline.HostSpans( + scanMillis = 40, + compileRpcMillis = 300, + policyMillis = 30, + dexRpcMillis = 80, + ), + ) + + assertThat(timeline.unaccountedMillis).isEqualTo(200) + assertThat(timeline.accountedMillis).isEqualTo(520) + } + + @Test + fun `a relink route accounts through the relink span, not through stage`() { + // A resources-only build never marks compileDone, so its relink lands in + // compileMillis rather than stageMillis. The accounting must not care which side of + // that boundary the work fell on - only that a span measured it. + val resourcesOnly = + E2eTimeline( + generation = 8, + trigger = 1_000, + compileDone = 1_650, + deploySent = 1_650, + reloadLive = 1_720, + spans = E2eTimeline.HostSpans(relinkRpcMillis = 650), + ) + + assertThat(resourcesOnly.stageMillis).isEqualTo(0) + assertThat(resourcesOnly.compileMillis).isEqualTo(650) + assertThat(resourcesOnly.unaccountedMillis).isEqualTo(0) + } + + @Test + fun `daemon-internal step timings never count toward the accounted total`() { + // kotlin/javac/strip/d8 and the snapshot phases run INSIDE the compile and dex RPCs. + // Adding them would double-count and drive the residual negative, hiding a real gap. + val timeline = + sample.copy( + spans = + E2eTimeline.HostSpans( + scanMillis = 40, + compileRpcMillis = 500, + policyMillis = 30, + dexRpcMillis = 80, + ), + steps = + E2eTimeline.StepTimings( + kotlinMillis = 300, + javaMillis = 100, + stripMillis = 40, + d8Millis = 35, + preSnapMillis = 20, + postSnapMillis = 25, + javaAbiSnapMillis = 50, + ), + ) + + assertThat(timeline.accountedMillis).isEqualTo(720) + assertThat(timeline.unaccountedMillis).isEqualTo(0) + } + + @Test + fun `no measured spans claims no residual`() { + // A pre-instrumentation daemon measures nothing. Reporting the whole build as + // "unaccounted" would be a false alarm, not an honest gap. + assertThat(sample.spans).isNull() + assertThat(sample.unaccountedMillis).isEqualTo(0) + assertThat(sample.accountedMillis).isEqualTo(70) + } + + @Test + fun `walkMillis sums the two output-tree snapshots and stays null when neither ran`() { + assertThat(E2eTimeline.StepTimings(preSnapMillis = 120, postSnapMillis = 130).walkMillis) + .isEqualTo(250) + assertThat(E2eTimeline.StepTimings(preSnapMillis = 120).walkMillis).isEqualTo(120) + assertThat(E2eTimeline.StepTimings(kotlinMillis = 5).walkMillis).isNull() + } + + @Test + fun `the new groups are absent-aware so an unreported group stays null`() { + assertThat(E2eTimeline.HostSpans().isEmpty()).isTrue() + assertThat(E2eTimeline.HostSpans(scanMillis = 1).isEmpty()).isFalse() + assertThat(E2eTimeline.BuildCounts().isEmpty()).isTrue() + assertThat(E2eTimeline.BuildCounts(compileOrdinal = 1).isEmpty()).isFalse() + assertThat(E2eTimeline.StepTimings().isEmpty()).isTrue() + assertThat(E2eTimeline.StepTimings(javaAbiSnapMillis = 1).isEmpty()).isFalse() + } + + @Test + fun `the log line takes the ordinal and nothing else as the new fields arrive`() { + // The harness greps this line. Spans, step timings, the other counts and the scratch + // filesystem stay off it and travel through the structured sinks; only the ordinal + // earned a place, because the stamps are unreadable without it. + val rich = + sample.copy( + spans = E2eTimeline.HostSpans(scanMillis = 40), + steps = E2eTimeline.StepTimings(kotlinMillis = 300), + counts = E2eTimeline.BuildCounts(allSources = 292, classBytes = 4096, compileOrdinal = 41), + scratchFsType = "fuse", + ) + + assertThat(rich.format()) + .isEqualTo( + "quickbuild-e2e: gen=7 trigger=1000 compileDone=1600 deploySent=1650 " + + "reloadLive=1720 compileOrdinal=41", + ) + } + + @Test + fun `the five stamps keep their order and lead the line, so an existing parser still matches`() { + // The benchmark harness's parser is an unanchored search for the five stamps in this + // order (harness/e2e_matrix_device.py, _LINE). Appending kept it matching; reordering + // or inserting would not, and would fail silently rather than loudly. + val line = sample.copy(counts = E2eTimeline.BuildCounts(compileOrdinal = 41)).format() + val stamps = + Regex( + "gen=(-?\\d+)\\s+trigger=(-?\\d+)\\s+compileDone=(-?\\d+)\\s+" + + "deploySent=(-?\\d+)\\s+reloadLive=(-?\\d+)", + ) + + val match = stamps.find(line) + assertThat(match).isNotNull() + assertThat(match!!.groupValues.drop(1)) + .containsExactly("7", "1000", "1600", "1650", "1720") + .inOrder() + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/watch/SaveCoalescingE2eTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/watch/SaveCoalescingE2eTest.kt new file mode 100644 index 0000000000..316de09c30 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/watch/SaveCoalescingE2eTest.kt @@ -0,0 +1,331 @@ +@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + +package org.appdevforall.cotg.quickbuild.domain.watch + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.data.AndroidProjectWatcher +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.ChangeClassifier +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.reload.BuildRequest +import org.appdevforall.cotg.quickbuild.domain.reload.LiveReloadExecutor +import org.appdevforall.cotg.quickbuild.domain.reload.LiveReloadOrchestrator +import org.appdevforall.cotg.quickbuild.domain.reload.OrchestratorEvent +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** + * End-to-end coalescing test for the save-to-build path - real files, real watcher, + * reconciler and orchestrator - pinning the BUILD COUNT a save pattern produces, which no + * single-layer test can see: the watcher's quiet window plus cap collapses one save's write + * burst, while the orchestrator folds everything arriving mid-build into one follow-up. + * Virtual time throughout; [AndroidProjectWatcher.report] stands in for inert FileObserver. + */ +class SaveCoalescingE2eTest { + @TempDir lateinit var tempDir: File + + /** + * Counts builds and records what each one read off disk when it started, which is how a + * dropped follow-up shows up as stale content rather than merely as a smaller count. + * + * @param buildMillis how long one build occupies the pipeline, in virtual time. Zero + * finishes without suspending, so a save always finds the pipeline free. + */ + private class RecordingExecutor( + private val buildMillis: Long, + ) : LiveReloadExecutor { + val requests = mutableListOf() + val contentSeen = mutableListOf() + private var generation = 0L + + override suspend fun execute(request: BuildRequest): BuildOutcome { + requests += request + contentSeen += readInputs(request.changes) + if (buildMillis > 0) delay(buildMillis) + return BuildOutcome.Success(generation = ++generation, durationMillis = buildMillis) + } + + /** The build's inputs as the compiler would find them: read at start, path order. */ + private fun readInputs(changes: ChangedFiles): String = + when (changes) { + is ChangedFiles.Known -> { + changes.files + .sortedBy(File::getPath) + .joinToString("|") { if (it.isFile) it.readText() else "" } + } + + ChangedFiles.Unknown -> { + "" + } + } + } + + /** + * The live pipeline under test plus the handles a test needs to drive it. + * + * @property src the watched source root; saves land under it. + */ + private class Harness( + val src: File, + private val watcher: AndroidProjectWatcher, + val executor: RecordingExecutor, + val events: MutableList, + ) { + /** + * One editor save of [name] with [text]: the write, then the MODIFY and CLOSE_WRITE + * inotify pair a single save actually produces. Collapsing that pair is the whole job + * of the quiet window, so a save that reported only once would test a weaker thing. + * + * @return the saved file, for asserting on the changed set. + */ + fun save( + name: String, + text: String, + ): File { + val file = + File(src, name).apply { + parentFile!!.mkdirs() + writeText(text) + } + watcher.report(file, fromPoll = false) + watcher.report(file, fromPoll = false) + return file + } + + /** Drives one mtime sweep, the path that can turn one save into two builds. */ + fun poll() = watcher.sweep() + + val buildCount: Int get() = executor.requests.size + + /** The changed set of build [index], which is always enumerated on this path. */ + fun filesOf(index: Int): Set = (executor.requests[index].changes as ChangedFiles.Known).files + } + + /** + * Wires watcher -> reconciler -> orchestrator exactly as + * `QuickBuildSessionManager.onWatcherBatch` does, on the test scheduler's clock. + * + * @param buildMillis virtual duration of every build; leave at zero for a pipeline that is + * always free, raise it above the save spacing to test in-flight coalescing. + */ + private fun TestScope.start(buildMillis: Long = 0L): Harness { + val src = File(tempDir, "app/src/main").apply { mkdirs() } + val executor = RecordingExecutor(buildMillis) + val events = mutableListOf() + val orchestrator = + LiveReloadOrchestrator( + executor, + ChangeClassifier(), + backgroundScope, + now = { testScheduler.currentTime }, + ) { events += it } + val watcher = + AndroidProjectWatcher( + watchedRoots = listOf(src), + watchedFiles = emptyList(), + filter = WatchFilter(listOf(src)), + // backgroundScope so the never-ending poll job is cancelled with the test. + scope = backgroundScope, + // Park the automatic sweep; a test that wants one calls poll(). + pollIntervalMillis = PARKED_POLL_MILLIS, + quietMillis = ChangeCoalescingDefaults.QUIET_MILLIS, + maxMillis = ChangeCoalescingDefaults.MAX_MILLIS, + pollDispatcher = StandardTestDispatcher(testScheduler), + ) + watcher.start { batch -> + val reconciled = WatcherBatchReconciler.reconcile(batch, File::isFile) + if (!reconciled.isEmpty) backgroundScope.launch { orchestrator.onFilesChanged(reconciled) } + } + // Run the poll loop's initFingerprints() pass before any edit, so the fingerprint + // state matches a long-running session's. + runCurrent() + return Harness(src, watcher, executor, events) + } + + /** Advances just past the quiet window, so a settled burst has flushed and started its build. */ + private fun TestScope.flushBurst() { + advanceTimeBy(ChangeCoalescingDefaults.QUIET_MILLIS + 1) + runCurrent() + } + + /** Advances past the cap as well, so nothing can still be accumulating anywhere. */ + private fun TestScope.settle() { + advanceTimeBy(ChangeCoalescingDefaults.MAX_MILLIS + 1) + runCurrent() + } + + @Test + fun `saves inside the quiet window are one build carrying the final content`() = + runTest { + val h = start() + + // Three saves of the same file, each well inside the quiet window - a fast typist + // hitting save, or an editor's own save-then-format pair. + h.save(SOURCE, "class A { fun a() = 1 }") + advanceTimeBy(ChangeCoalescingDefaults.QUIET_MILLIS / 3) + runCurrent() + h.save(SOURCE, "class A { fun a() = 12 }") + advanceTimeBy(ChangeCoalescingDefaults.QUIET_MILLIS / 3) + runCurrent() + val file = h.save(SOURCE, "class A { fun a() = 123 }") + settle() + + assertThat(h.buildCount).isEqualTo(1) + assertThat(h.filesOf(0)).containsExactly(file) + // The one build compiled the LAST save, not the first: coalescing may drop a build, + // never an edit. + assertThat(h.executor.contentSeen).containsExactly("class A { fun a() = 123 }") + } + + @Test + fun `saves arriving during a build become one follow-up build, not one each`() = + runTest { + val h = start(buildMillis = LONG_BUILD_MILLIS) + + h.save(SOURCE, "class A") + flushBurst() + assertThat(h.buildCount).isEqualTo(1) + + // Three more saves, each its own settled batch (spaced beyond the quiet window, so + // the watcher does NOT coalesce them) landing while build 1 is still running. + listOf("java/B.kt" to "class B", "java/C.kt" to "class C", "java/D.kt" to "class D") + .forEach { (name, text) -> + h.save(name, text) + flushBurst() + } + + // Nothing queued behind the in-flight build: three batches, still one build. + assertThat(h.buildCount).isEqualTo(1) + + advanceTimeBy(LONG_BUILD_MILLIS) + runCurrent() + + // Exactly one follow-up, carrying all three files at once. + assertThat(h.buildCount).isEqualTo(2) + assertThat(h.filesOf(1)) + .containsExactly( + File(h.src, "java/B.kt"), + File(h.src, "java/C.kt"), + File(h.src, "java/D.kt"), + ) + + // And no third build behind that one. + advanceTimeBy(LONG_BUILD_MILLIS + ChangeCoalescingDefaults.MAX_MILLIS) + runCurrent() + assertThat(h.buildCount).isEqualTo(2) + } + + @Test + fun `the newest save wins when several land during one build`() = + runTest { + val h = start(buildMillis = LONG_BUILD_MILLIS) + + h.save(SOURCE, "v1") + flushBurst() + assertThat(h.buildCount).isEqualTo(1) + + // Bryan's pattern, but faster than a build: delete a character, save, repeat. Each + // save is its own watcher batch; all of them coalesce into one follow-up. + h.save(SOURCE, "v2") + flushBurst() + h.save(SOURCE, "v3") + flushBurst() + + advanceTimeBy(LONG_BUILD_MILLIS) + runCurrent() + + // The follow-up must exist AND must have compiled v3. A coalescer that dropped the + // follow-up would leave the phone running v1 with the user looking at v3. + assertThat(h.buildCount).isEqualTo(2) + assertThat(h.executor.contentSeen).containsExactly("v1", "v3").inOrder() + } + + @Test + fun `saves spaced beyond the quiet window each get their own build`() = + runTest { + // The negative case, and Bryan's manual-QA pattern exactly: a character deleted and + // saved every few hundred ms, with each build finishing before the next save. Four + // builds is correct - the quiet window collapses one save's writes, and is not a + // throttle on a user who keeps asking. + val h = start() + val spacing = ChangeCoalescingDefaults.QUIET_MILLIS * 4 + + repeat(4) { i -> + h.save(SOURCE, "v$i") + advanceTimeBy(spacing) + runCurrent() + } + settle() + + assertThat(h.buildCount).isEqualTo(4) + assertThat(h.executor.contentSeen).containsExactly("v0", "v1", "v2", "v3").inOrder() + } + + @Test + fun `a continuous write stream builds on the cap, not per write`() = + runTest { + // Codegen or a git checkout writing without a gap: the quiet timer keeps resetting, + // so only the cap can flush. Far fewer builds than writes, and the last write is + // still compiled. + val h = start() + val step = ChangeCoalescingDefaults.QUIET_MILLIS / 2 + val writes = (ChangeCoalescingDefaults.MAX_MILLIS * 2 / step).toInt() + + repeat(writes) { i -> + h.save(SOURCE, "w$i") + advanceTimeBy(step) + runCurrent() + } + settle() + + // 26 writes, two builds: the cap flushes the first batch at MAX_MILLIS, and the + // second flushes on the quiet window once the stream stops - before its own cap. + assertThat(writes).isEqualTo(26) + assertThat(h.buildCount).isEqualTo(2) + assertThat(h.executor.contentSeen.last()).isEqualTo("w${writes - 1}") + } + + @Test + fun `a poll sweep after a settled save adds no second build`() = + runTest { + // The phantom-double-build shape: inotify delivered the save, then the 2s mtime + // sweep saw a stamp it had not restamped and re-emitted the same edit. + val h = start() + + h.save(SOURCE, "class A { fun a() = 1 }") + settle() + assertThat(h.buildCount).isEqualTo(1) + + h.poll() + settle() + assertThat(h.buildCount).isEqualTo(1) + } + + @Test + fun `the debounce window these cases are driven off is the production one`() { + // The cases above take the window from these constants, so they follow a retune rather + // than failing on it. This is the one place a retune is a deliberate decision: 150 ms is + // short enough that a save still feels immediate, and the 1 s cap keeps a continuous + // write stream from deferring a build indefinitely. + assertThat(ChangeCoalescingDefaults.QUIET_MILLIS).isEqualTo(150L) + assertThat(ChangeCoalescingDefaults.MAX_MILLIS).isEqualTo(1_000L) + } + + private companion object { + private const val SOURCE = "java/A.kt" + + /** Long enough that every save in an in-flight test lands before the build ends. */ + private const val LONG_BUILD_MILLIS = 5_000L + + /** An hour: the automatic sweep never fires, so tests drive poll() themselves. */ + private const val PARKED_POLL_MILLIS = 3_600_000L + } +} 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 new file mode 100644 index 0000000000..97a668b076 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt @@ -0,0 +1,69 @@ +package org.appdevforall.cotg.quickbuild.service + +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 + +/** Recording [DeploySender] with a scripted result. */ +class FakeDeploy : DeploySender { + data class Call( + val generation: Long, + val dexFile: File?, + val arscFile: File?, + val assetsZip: File?, + val metadataJson: String, + ) + + val calls = mutableListOf() + val statusCalls = mutableListOf() + val awaitDisconnectCalls = mutableListOf() + val awaitReconnectCalls = mutableListOf() + var result: DeployResult = DeployResult.Reloaded(40) + + /** When non-empty, each deploy consumes the next entry instead of [result]. */ + val resultQueue = ArrayDeque() + var disconnects: Boolean = true + + /** + * Generation the fake "relaunched app" reconnects at, given the last deployed + * generation; return null for a relaunch that never reconnects. Defaults to a + * clean restart (reconnects at the deployed generation). + */ + var reconnectGeneration: (deployedGeneration: Long?) -> Long? = { it } + + override suspend fun deploy( + generation: Long, + dexFile: File?, + arscFile: File?, + assetsZip: File?, + metadataJson: String, + ): DeployResult { + calls += Call(generation, dexFile, arscFile, assetsZip, metadataJson) + return resultQueue.removeFirstOrNull() ?: result + } + + override fun notifyBuildStatus(statusJson: String) { + statusCalls += statusJson + } + + override suspend fun awaitDisconnect(timeoutMillis: Long): Boolean { + awaitDisconnectCalls += timeoutMillis + return disconnects + } + + override suspend fun awaitReconnect(timeoutMillis: Long): Long? { + awaitReconnectCalls += timeoutMillis + return reconnectGeneration(calls.lastOrNull()?.generation) + } +} + +class MemoryGenerationStore : GenerationStore { + var value: Long? = null + + override fun load(): Long? = value + + override fun save(generation: Long) { + value = generation + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/BuildStatusJsonTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/BuildStatusJsonTest.kt new file mode 100644 index 0000000000..390f90e832 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/BuildStatusJsonTest.kt @@ -0,0 +1,129 @@ +package org.appdevforall.cotg.quickbuild.service.deploy + +import com.google.common.truth.Truth.assertThat +import com.google.gson.JsonParser +import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic +import org.junit.jupiter.api.Test + +class BuildStatusJsonTest { + private fun parse(json: String) = JsonParser.parseString(json).asJsonObject + + private fun error( + message: String, + file: String? = null, + line: Int? = null, + column: Int? = null, + ) = BuildDiagnostic(BuildDiagnostic.Severity.ERROR, message, file, line, column) + + @Test + fun `encodes the first error with string-only values`() { + val json = + BuildStatusJson.buildFailed( + listOf(error("Unresolved reference: foo", "/p/src/Foo.kt", 12, 5)), + ) + + val obj = parse(json) + assertThat(obj.get("kind").asString).isEqualTo("build_failed") + assertThat(obj.get("message").asString).isEqualTo("Unresolved reference: foo") + assertThat(obj.has("moreErrors")).isFalse() + } + + @Test + fun `never sends the error location to the device`() { + // Jumping to an error is CoGo-side functionality; the runtime has no use for a + // host-side path, so position data stays off the deploy channel entirely. + val obj = + parse( + BuildStatusJson.buildFailed( + listOf(error("Unresolved reference: foo", "/p/src/Foo.kt", 12, 5)), + ), + ) + + assertThat(obj.has("file")).isFalse() + assertThat(obj.has("line")).isFalse() + assertThat(obj.has("column")).isFalse() + assertThat(obj.keySet()).containsExactly("kind", "message") + } + + @Test + fun `reinstall pending is kind-only`() { + // The copy is static and lives runtime-side with the other overlay text, so the + // wire carries nothing but the kind. + val obj = parse(BuildStatusJson.reinstallPending()) + + assertThat(obj.get("kind").asString).isEqualTo("reinstall_pending") + assertThat(obj.keySet()).containsExactly("kind") + } + + @Test + fun `keeps only the first line of a multi-line message`() { + val json = + BuildStatusJson.buildFailed( + listOf(error("first line\nsecond line\nthird", "/p/A.kt", 1)), + ) + + assertThat(parse(json).get("message").asString).isEqualTo("first line") + } + + @Test + fun `prefers the first ERROR over earlier warnings and counts the rest`() { + val warning = BuildDiagnostic(BuildDiagnostic.Severity.WARNING, "meh", "/p/W.kt", 1) + val json = + BuildStatusJson.buildFailed( + listOf(warning, error("real problem", "/p/E.kt", 7), error("another", "/p/E2.kt", 9)), + ) + + val obj = parse(json) + assertThat(obj.get("message").asString).isEqualTo("real problem") + assertThat(obj.get("moreErrors").asString).isEqualTo("1") + } + + @Test + fun `falls back to the first diagnostic when there is no ERROR`() { + val warning = BuildDiagnostic(BuildDiagnostic.Severity.WARNING, "warn only", "/p/W.kt", 2) + val json = BuildStatusJson.buildFailed(listOf(warning)) + + val obj = parse(json) + assertThat(obj.get("message").asString).isEqualTo("warn only") + assertThat(obj.has("moreErrors")).isFalse() + } + + @Test + fun `empty diagnostics still encode a valid failure`() { + val obj = parse(BuildStatusJson.buildFailed(emptyList())) + assertThat(obj.get("kind").asString).isEqualTo("build_failed") + } + + @Test + fun `buildOk encodes only the kind`() { + val obj = parse(BuildStatusJson.buildOk()) + assertThat(obj.get("kind").asString).isEqualTo("build_ok") + assertThat(obj.size()).isEqualTo(1) + } + + @Test + fun `building encodes the kind and running generation as strings`() { + val obj = parse(BuildStatusJson.building(5L)) + assertThat(obj.get("kind").asString).isEqualTo("building") + assertThat(obj.get("runningGeneration").asJsonPrimitive.isString).isTrue() + assertThat(obj.get("runningGeneration").asString).isEqualTo("5") + } + + @Test + fun `building encodes a zero generation the same way as any other`() { + val obj = parse(BuildStatusJson.building(0L)) + assertThat(obj.get("runningGeneration").asString).isEqualTo("0") + } + + @Test + fun `wire format round-trips through the runtime parser contract`() { + // Gson must escape what MiniJson unescapes - quotes, backslashes, newlines. + val json = + BuildStatusJson.buildFailed( + listOf(error("expecting '\"' after \\ in C:\\path", "/p/Q.kt", 3)), + ) + + assertThat(parse(json).get("message").asString) + .isEqualTo("expecting '\"' after \\ in C:\\path") + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannelDeployTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannelDeployTest.kt new file mode 100644 index 0000000000..fb59367865 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannelDeployTest.kt @@ -0,0 +1,189 @@ +@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + +package org.appdevforall.cotg.quickbuild.service.deploy + +import android.os.IBinder +import android.os.ParcelFileDescriptor +import android.os.RemoteException +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.quickbuild.IQuickBuildTarget +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.service.telemetry.report +import org.junit.jupiter.api.Test + +/** + * The real [DeployChannel.deploy] verdict machinery against a real + * [ProxyAppConnections] and a scripted [IQuickBuildTarget]: report matching by + * generation, the disconnect and binder-failure verdicts, and the timeout fallback. + * (The Android `ParcelFileDescriptor` stubs no-op on the JVM, so payload files stay + * null-or-ignored here; fd plumbing is device territory.) + */ +class DeployChannelDeployTest { + private val connections = ProxyAppConnections() + private val channel = DeployChannel(connections, timeoutMillis = 5_000) + + /** Records payload calls; can be scripted to throw at the binder boundary. */ + private class ScriptedTarget( + private val onPayloadThrow: (() -> Throwable)? = null, + ) : IQuickBuildTarget { + val payloads = mutableListOf>() + val statuses = mutableListOf() + var statusThrow: (() -> Throwable)? = null + + override fun asBinder(): IBinder? = null + + override fun onPayload( + generation: Long, + dexPayload: ParcelFileDescriptor?, + resourcesPayload: ParcelFileDescriptor?, + assetsPayload: ParcelFileDescriptor?, + metadataJson: String?, + ) { + onPayloadThrow?.let { throw it() } + payloads += generation to metadataJson + } + + override fun onBuildStatus(statusJson: String?) { + statusThrow?.let { throw it() } + statuses += statusJson + } + } + + private fun connect( + target: ScriptedTarget, + generation: Long = 0, + ) = connections.onConnected(ConnectedTarget(target, "com.example.quickbuild", generation)) + + @Test + fun `deploy without a connected proxy app reports NotConnected`() = + runTest { + val result = channel.deploy(1, null, null, null, "{}") + + assertThat(result).isEqualTo(DeployResult.NotConnected) + } + + @Test + fun `a reload report for the deployed generation completes the deploy`() = + runTest { + val target = ScriptedTarget() + connect(target) + + val deploy = async { channel.deploy(7, null, null, null, """{"gen":7}""") } + runCurrent() + connections.report(TargetReport.Reloaded(generation = 7, reloadMillis = 42)) + + assertThat(deploy.await()).isEqualTo(DeployResult.Reloaded(42)) + assertThat(target.payloads).containsExactly(7L to """{"gen":7}""") + } + + @Test + fun `reports for other generations are ignored, not misattributed`() = + runTest { + val target = ScriptedTarget() + connect(target) + + val deploy = async { channel.deploy(7, null, null, null, "{}") } + runCurrent() + // Late reports from a superseded generation must not complete this deploy. + connections.report(TargetReport.Reloaded(generation = 6, reloadMillis = 5)) + connections.report(TargetReport.Crashed(generation = 6, stackSummary = "old crash")) + runCurrent() + assertThat(deploy.isCompleted).isFalse() + + connections.report(TargetReport.Reloaded(generation = 7, reloadMillis = 99)) + assertThat(deploy.await()).isEqualTo(DeployResult.Reloaded(99)) + } + + @Test + fun `a crash report for the deployed generation reports Crashed with the stack`() = + runTest { + val target = ScriptedTarget() + connect(target) + + val deploy = async { channel.deploy(3, null, null, null, "{}") } + runCurrent() + connections.report(TargetReport.Crashed(generation = 3, stackSummary = "NPE at MainActivity")) + + assertThat(deploy.await()).isEqualTo(DeployResult.Crashed("NPE at MainActivity")) + } + + @Test + fun `a disconnect while awaiting the verdict reports Disconnected`() = + runTest { + val target = ScriptedTarget() + connect(target) + + val deploy = async { channel.deploy(3, null, null, null, "{}") } + runCurrent() + connections.onDisconnected() + + assertThat(deploy.await()).isEqualTo(DeployResult.Disconnected) + } + + @Test + fun `a binder failure during onPayload reports Failed naming the binder`() = + runTest { + connect(ScriptedTarget(onPayloadThrow = { RemoteException("binder gone") })) + + val result = channel.deploy(3, null, null, null, "{}") + + assertThat(result).isInstanceOf(DeployResult.Failed::class.java) + assertThat((result as DeployResult.Failed).message).contains("Binder call failed") + } + + @Test + fun `an unopenable payload reports Failed naming the payload`() = + runTest { + connect(ScriptedTarget(onPayloadThrow = { java.io.IOException("fd refused") })) + + val result = channel.deploy(3, null, null, null, "{}") + + assertThat(result).isInstanceOf(DeployResult.Failed::class.java) + assertThat((result as DeployResult.Failed).message).contains("Cannot open payload") + } + + @Test + fun `a proxy app that never answers times out with the configured timeout`() = + runTest { + val target = ScriptedTarget() + connect(target) + + val result = channel.deploy(3, null, null, null, "{}") + + // runTest's virtual clock skips the 5s wait; no report ever arrives. + assertThat(result).isEqualTo(DeployResult.TimedOut(5_000)) + assertThat(target.payloads).hasSize(1) + } + + @Test + fun `notifyBuildStatus reaches the connected proxy app`() = + runTest { + val target = ScriptedTarget() + connect(target) + + channel.notifyBuildStatus("""{"state":"building"}""") + + assertThat(target.statuses).containsExactly("""{"state":"building"}""") + } + + @Test + fun `notifyBuildStatus without a connection is a silent no-op`() = + runTest { + // Nothing to assert beyond "did not throw": the contract is fire-and-forget. + channel.notifyBuildStatus("""{"state":"building"}""") + } + + @Test + fun `a throwing status stub stays best-effort`() = + runTest { + val target = ScriptedTarget() + target.statusThrow = { RemoteException("stub predates onBuildStatus") } + connect(target) + + channel.notifyBuildStatus("""{"state":"building"}""") + + assertThat(target.statuses).isEmpty() + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannelWaitsTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannelWaitsTest.kt new file mode 100644 index 0000000000..13a710008a --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannelWaitsTest.kt @@ -0,0 +1,92 @@ +@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + +package org.appdevforall.cotg.quickbuild.service.deploy + +import android.os.IBinder +import android.os.ParcelFileDescriptor +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.quickbuild.IQuickBuildTarget +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +/** + * The real [DeployChannel]'s two restart-path waits, against a real + * [ProxyAppConnections] - the executor's own suite only ever sees a fake channel, which + * is how a disconnect that reported itself as a timeout shipped and made every + * restart deploy fall back to a rebaseline on device (2026-07-22 QA walk). + */ +class DeployChannelWaitsTest { + private val connections = ProxyAppConnections() + private val channel = DeployChannel(connections) + + /** Never called: the waits only read the connection StateFlow. */ + private val target = + object : IQuickBuildTarget { + override fun asBinder(): IBinder? = null + + override fun onPayload( + generation: Long, + dexPayload: ParcelFileDescriptor?, + resourcesPayload: ParcelFileDescriptor?, + assetsPayload: ParcelFileDescriptor?, + metadataJson: String?, + ) = Unit + + override fun onBuildStatus(statusJson: String?) = Unit + } + + private fun connect(generation: Long) = connections.onConnected(ConnectedTarget(target, "com.example.quickbuild", generation)) + + @Test + fun `awaitDisconnect reports true when the proxy app actually disconnects`() = + runTest { + connect(generation = 7) + val awaited = async { channel.awaitDisconnect(5_000) } + runCurrent() + + connections.onDisconnected() + + assertThat(awaited.await()).isTrue() + } + + @Test + fun `awaitDisconnect reports false when the proxy app stays connected`() = + runTest { + connect(generation = 7) + val awaited = async { channel.awaitDisconnect(5_000) } + + advanceTimeBy(5_001) + + assertThat(awaited.await()).isFalse() + } + + @Test + fun `awaitDisconnect reports true immediately when nothing is connected`() = + runTest { + assertThat(channel.awaitDisconnect(5_000)).isTrue() + } + + @Test + fun `awaitReconnect returns the generation the fresh process reported`() = + runTest { + val awaited = async { channel.awaitReconnect(15_000) } + runCurrent() + + connect(generation = 8) + + assertThat(awaited.await()).isEqualTo(8) + } + + @Test + fun `awaitReconnect returns null when nothing reconnects in time`() = + runTest { + val awaited = async { channel.awaitReconnect(15_000) } + + advanceTimeBy(15_001) + + assertThat(awaited.await()).isNull() + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerRetentionTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerRetentionTest.kt new file mode 100644 index 0000000000..e598a81789 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerRetentionTest.kt @@ -0,0 +1,164 @@ +package org.appdevforall.cotg.quickbuild.service.deploy + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.data.AssetPackager +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind +import org.appdevforall.cotg.quickbuild.domain.reload.DeployDecision +import org.appdevforall.cotg.quickbuild.domain.reload.GenerationTracker +import org.appdevforall.cotg.quickbuild.service.FakeDeploy +import org.appdevforall.cotg.quickbuild.service.MemoryGenerationStore +import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppLauncher +import org.appdevforall.cotg.quickbuild.service.telemetry.E2eTimelineRecorder +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** + * Retention side of [PayloadDeployer] (concurrency.md rules 3-4): a deploy the proxy app + * confirmed leaves its bytes in the [RetainedPayloadStore] for the reconnect re-send, and an + * unconfirmed one leaves the store exactly as it was. + */ +class PayloadDeployerRetentionTest { + @TempDir lateinit var workDir: File + + private val deploy = FakeDeploy() + private val store by lazy { RetainedPayloadStore.forWorkDir(workDir) } + + private fun deployer() = + PayloadDeployer( + deploy = deploy, + generations = GenerationTracker(MemoryGenerationStore()), + entryActivity = "com.example.app.MainActivity", + proxyAppPackage = "com.example.app", + launcherActivity = "com.example.app.Proxy0Activity", + launcher = ProxyAppLauncher { _, _ -> true }, + restartDisconnectTimeoutMillis = 5_000, + restartReconnectTimeoutMillis = 15_000, + clock = { 1_000 }, + reportTimeline = {}, + retention = store, + ) + + private fun recorder() = E2eTimelineRecorder(trigger = 0) { null } + + private fun artifact( + name: String, + content: String, + ): File = File(workDir, name).apply { writeText(content) } + + @Test + fun `a confirmed hot-swap deploy retains its payload at the deployed generation`() = + runTest { + val dex = artifact("built.dex", "dex-bytes") + val assetsZip = artifact("assets-payload.zip", "assets-bytes") + + val outcome = + deployer().deploy( + DeployDecision.Recreate, + dex, + null, + AssetPackager.PackagedAssets(assetsZip, listOf("data/levels.json")), + loopStartedAt = 0, + recorder = recorder(), + ) + + assertThat(outcome).isInstanceOf(BuildOutcome.Success::class.java) + val retained = store.load()!! + assertThat(retained.generation).isEqualTo((outcome as BuildOutcome.Success).generation) + assertThat(retained.dexFile!!.readText()).isEqualTo("dex-bytes") + assertThat(retained.arscFile).isNull() + assertThat(retained.assetsZip!!.readText()).isEqualTo("assets-bytes") + assertThat(retained.metadataJson).contains("com.example.app.MainActivity") + } + + @Test + fun `the next confirmed deploy replaces the retained set`() = + runTest { + val deployer = deployer() + deployer.deploy( + DeployDecision.Recreate, + artifact("built.dex", "gen-1-dex"), + null, + null, + loopStartedAt = 0, + recorder = recorder(), + ) + deployer.deploy( + DeployDecision.Recreate, + artifact("built.dex", "gen-2-dex"), + artifact("built.arsc", "gen-2-arsc"), + null, + loopStartedAt = 0, + recorder = recorder(), + ) + + val retained = store.load()!! + assertThat(retained.generation).isEqualTo(2L) + assertThat(retained.dexFile!!.readText()).isEqualTo("gen-2-dex") + assertThat(retained.arscFile!!.readText()).isEqualTo("gen-2-arsc") + } + + @Test + fun `a failed deploy retains nothing`() = + runTest { + deploy.result = DeployResult.Failed("binder broke") + + deployer().deploy( + DeployDecision.Recreate, + artifact("built.dex", "dex-bytes"), + null, + null, + loopStartedAt = 0, + recorder = recorder(), + ) + + // The proxy app never confirmed these bytes; re-sending them on a reconnect + // would claim a generation the app never ran. + assertThat(store.load()).isNull() + } + + @Test + fun `a confirmed restart deploy retains hot-swap metadata, not the restart flag`() = + runTest { + deploy.result = DeployResult.Reloaded(40) + + val outcome = + deployer().deploy( + DeployDecision.Restart(ComponentKind.SERVICE, "com.example.app.SyncService"), + artifact("built.dex", "dex-bytes"), + null, + null, + loopStartedAt = 0, + recorder = recorder(), + ) + + assertThat(outcome).isInstanceOf(BuildOutcome.Success::class.java) + val retained = store.load()!! + // The deploy itself carried restart=true; the re-send must not, or a reconnect + // catch-up would ask the just-relaunched app to persist and exit again. + assertThat(retained.metadataJson).doesNotContain("restart") + assertThat(retained.dexFile!!.readText()).isEqualTo("dex-bytes") + } + + @Test + fun `a restart deploy whose relaunch never comes back retains nothing`() = + runTest { + deploy.result = DeployResult.Reloaded(40) + deploy.reconnectGeneration = { null } + + val outcome = + deployer().deploy( + DeployDecision.Restart(ComponentKind.SERVICE, "com.example.app.SyncService"), + artifact("built.dex", "dex-bytes"), + null, + null, + loopStartedAt = 0, + recorder = recorder(), + ) + + assertThat(outcome).isInstanceOf(BuildOutcome.DeployFailure::class.java) + assertThat(store.load()).isNull() + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerTest.kt new file mode 100644 index 0000000000..03ba73bbc0 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerTest.kt @@ -0,0 +1,297 @@ +package org.appdevforall.cotg.quickbuild.service.deploy + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind +import org.appdevforall.cotg.quickbuild.domain.reload.DeployDecision +import org.appdevforall.cotg.quickbuild.domain.reload.GenerationTracker +import org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline +import org.appdevforall.cotg.quickbuild.service.FakeDeploy +import org.appdevforall.cotg.quickbuild.service.MemoryGenerationStore +import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppLauncher +import org.appdevforall.cotg.quickbuild.service.telemetry.E2eTimelineRecorder +import org.junit.jupiter.api.Test + +class PayloadDeployerTest { + private val deploy = FakeDeploy() + private val timelines = mutableListOf() + private val launchCalls = mutableListOf>() + private var launchResult = true + + private fun deployer( + proxyAppPackage: String? = "com.example.app", + withLauncher: Boolean = true, + userInitiated: Boolean = true, + ) = PayloadDeployer( + deploy = deploy, + generations = GenerationTracker(MemoryGenerationStore()), + entryActivity = "com.example.app.MainActivity", + proxyAppPackage = proxyAppPackage, + launcherActivity = "com.example.app.Proxy0Activity", + launcher = + if (withLauncher) { + ProxyAppLauncher { packageName, activityClass -> + launchCalls += packageName to activityClass + launchResult + } + } else { + null + }, + restartDisconnectTimeoutMillis = 5_000, + restartReconnectTimeoutMillis = 15_000, + clock = { 1_000 }, + reportTimeline = timelines::add, + userInitiated = { userInitiated }, + ) + + private fun recorder() = E2eTimelineRecorder(trigger = 0) { null } + + private val restart = DeployDecision.Restart(ComponentKind.SERVICE, "com.example.app.SyncService") + + private suspend fun deployRestart(deployer: PayloadDeployer): BuildOutcome = + deployer.deploy(restart, null, null, null, loopStartedAt = 0, recorder = recorder()) + + @Test + fun `restart reconnect below the deployed generation requires a proxy app rebuild`() = + runTest { + deploy.result = DeployResult.Reloaded(40) + deploy.reconnectGeneration = { deployed -> (deployed ?: 1) - 1 } + val outcome = deployRestart(deployer()) + assertThat(outcome).isInstanceOf(BuildOutcome.RequiresProxyAppRebuild::class.java) + assertThat((outcome as BuildOutcome.RequiresProxyAppRebuild).detail) + .contains("did not persist") + } + + @Test + fun `restart reconnect at the deployed generation is a restarted success`() = + runTest { + deploy.result = DeployResult.Reloaded(40) + val outcome = deployRestart(deployer()) + assertThat(outcome).isInstanceOf(BuildOutcome.Success::class.java) + assertThat((outcome as BuildOutcome.Success).restarted).isTrue() + assertThat(timelines).hasSize(1) + // The exit wait is bounded by the DISCONNECT timeout, not the reconnect one: + // waiting a reconnect-sized 15s for a process that already died is 10s of dead + // air on every service edit. + assertThat(deploy.awaitDisconnectCalls).containsExactly(5_000L) + } + + @Test + fun `restart ack without binder death names the pre-restart runtime`() = + runTest { + deploy.result = DeployResult.Reloaded(40) + deploy.disconnects = false + val outcome = deployRestart(deployer()) + assertThat(outcome).isInstanceOf(BuildOutcome.RequiresProxyAppRebuild::class.java) + assertThat((outcome as BuildOutcome.RequiresProxyAppRebuild).detail) + .contains("predates restart support") + } + + /** + * Characterization, not endorsement: the restart route relaunches unconditionally, so a + * plain save touching a Service or Receiver steals focus. Deferred, not overlooked - a + * restart cannot finish without the process coming back, so suppressing the relaunch needs + * a decision about what a half-restarted app does, not a one-line gate. Pinned so gating + * on [userInitiated] turns this red and whoever does it has to say so here. + */ + @Test + fun `a save that restarts a component relaunches the app - the deferred focus steal`() = + runTest { + deploy.result = DeployResult.Reloaded(40) + + val outcome = deployRestart(deployer(userInitiated = false)) + + assertThat(launchCalls).containsExactly("com.example.app" to "com.example.app.Proxy0Activity") + assertThat(outcome).isInstanceOf(BuildOutcome.Success::class.java) + assertThat((outcome as BuildOutcome.Success).restarted).isTrue() + } + + @Test + fun `NotConnected relaunches and retries exactly once, never a loop`() = + runTest { + // Both attempts NotConnected: recovery must launch once, retry once, then stop. + deploy.result = DeployResult.NotConnected + val outcome = + deployer().deploy( + DeployDecision.Recreate, + null, + null, + null, + loopStartedAt = 0, + recorder = recorder(), + ) + assertThat(launchCalls).hasSize(1) + assertThat(deploy.calls).hasSize(2) + // Started and still absent across both attempts: real cannot-stay-up evidence. + assertThat((outcome as BuildOutcome.DeployFailure).proxyAppNotConnected).isTrue() + } + + /** + * A launch that never started is not evidence the app cannot stay up - nothing ran to + * fail. Distinguishing this from a started-but-absent app is the whole point of + * tracking the launch rather than inferring it from who asked for the build. + */ + @Test + fun `a launch that fails to start is not evidence the app cannot stay up`() = + runTest { + deploy.result = DeployResult.NotConnected + launchResult = false + + val outcome = + deployer().deploy( + DeployDecision.Recreate, + null, + null, + null, + loopStartedAt = 0, + recorder = recorder(), + ) + + assertThat(launchCalls).hasSize(1) + // No retry: the retry only follows an app that actually started. + assertThat(deploy.calls).hasSize(1) + assertThat((outcome as BuildOutcome.DeployFailure).proxyAppNotConnected).isFalse() + } + + /** Started, then never came back within the window - the app really cannot stay up. */ + @Test + fun `an app that starts but never reconnects is evidence it cannot stay up`() = + runTest { + deploy.result = DeployResult.NotConnected + deploy.reconnectGeneration = { null } + + val outcome = + deployer().deploy( + DeployDecision.Recreate, + null, + null, + null, + loopStartedAt = 0, + recorder = recorder(), + ) + + assertThat(launchCalls).hasSize(1) + assertThat(deploy.calls).hasSize(1) + assertThat((outcome as BuildOutcome.DeployFailure).proxyAppNotConnected).isTrue() + } + + /** + * The behaviour Bryan asked for: a save builds, but it never takes the screen. Starting an + * activity is unconditionally a foreground steal on Android, so the only way to honour that + * is to not start one. + */ + @Test + fun `a save whose app is closed never launches it, and does not retry`() = + runTest { + deploy.result = DeployResult.NotConnected + val outcome = + deployer(userInitiated = false).deploy( + DeployDecision.Recreate, + null, + null, + null, + loopStartedAt = 0, + recorder = recorder(), + ) + assertThat(launchCalls).isEmpty() + // One attempt only: the retry exists solely to follow a launch. + assertThat(deploy.calls).hasSize(1) + assertThat(outcome).isInstanceOf(BuildOutcome.DeployFailure::class.java) + } + + /** + * proxyAppNotConnected is the evidence a repeat escalates into the cannot-stay-up dialog, so + * it must mean "launched and still absent". A save never launches, so an app nobody has + * opened must not be accused of crashing on startup. + */ + @Test + fun `a save's not-connected deploy is not evidence the app cannot stay up`() = + runTest { + deploy.result = DeployResult.NotConnected + val saved = + deployer(userInitiated = false).deploy( + DeployDecision.Recreate, + null, + null, + null, + loopStartedAt = 0, + recorder = recorder(), + ) + assertThat((saved as BuildOutcome.DeployFailure).proxyAppNotConnected).isFalse() + + launchCalls.clear() + deploy.calls.clear() + val tapped = + deployer(userInitiated = true).deploy( + DeployDecision.Recreate, + null, + null, + null, + loopStartedAt = 0, + recorder = recorder(), + ) + assertThat((tapped as BuildOutcome.DeployFailure).proxyAppNotConnected).isTrue() + } + + @Test + fun `NotConnected with no proxy app package returns without attempting anything`() = + runTest { + deploy.result = DeployResult.NotConnected + val outcome = + deployer(proxyAppPackage = null).deploy( + DeployDecision.Recreate, + null, + null, + null, + loopStartedAt = 0, + recorder = recorder(), + ) + assertThat(launchCalls).isEmpty() + assertThat(deploy.calls).hasSize(1) + // Nothing was launched, so this is not cannot-stay-up evidence. + assertThat((outcome as BuildOutcome.DeployFailure).proxyAppNotConnected).isFalse() + } + + @Test + fun `NotConnected with no launcher returns without attempting anything`() = + runTest { + deploy.result = DeployResult.NotConnected + val outcome = + deployer(withLauncher = false).deploy( + DeployDecision.Recreate, + null, + null, + null, + loopStartedAt = 0, + recorder = recorder(), + ) + assertThat(launchCalls).isEmpty() + assertThat(deploy.calls).hasSize(1) + assertThat((outcome as BuildOutcome.DeployFailure).proxyAppNotConnected).isFalse() + } + + @Test + fun `rebuild-proxy-app decision refuses before any deploy goes out`() = + runTest { + val outcome = + deployer().deploy( + DeployDecision.RebuildProxyApp("baseline predates component metadata"), + null, + null, + null, + loopStartedAt = 0, + recorder = recorder(), + ) + assertThat(outcome).isInstanceOf(BuildOutcome.RequiresProxyAppRebuild::class.java) + assertThat(deploy.calls).isEmpty() + } + + @Test + fun `restart success carries the restart metadata flag`() = + runTest { + deploy.result = DeployResult.Reloaded(40) + deployRestart(deployer()) + assertThat(deploy.calls.single().metadataJson).contains("\"restart\":\"true\"") + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppConnectionsFreezerHoldTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppConnectionsFreezerHoldTest.kt new file mode 100644 index 0000000000..39045f2604 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppConnectionsFreezerHoldTest.kt @@ -0,0 +1,153 @@ +package org.appdevforall.cotg.quickbuild.service.deploy + +import android.os.IBinder +import android.os.ParcelFileDescriptor +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.quickbuild.IQuickBuildTarget +import org.junit.jupiter.api.Test + +/** + * When [ProxyAppConnections] takes and drops the freezer hold. + * + * The bug this pins: with no hold, Android freezes the backgrounded proxy app about a minute + * after it loses the foreground, it stops answering the reload handshake, and every save then + * fails the 15 s deploy timeout. So the hold follows the *connection*, not the session alone: + * taken when an app is there to protect, dropped the moment it is gone or the session ends. + */ +class ProxyAppConnectionsFreezerHoldTest { + private val connections = ProxyAppConnections() + private val hold = RecordingHold() + + init { + connections.installPriorityHold(hold) + } + + /** Never called: the hold lifecycle only reads the registry's own state. */ + private val target = + object : IQuickBuildTarget { + override fun asBinder(): IBinder? = null + + override fun onPayload( + generation: Long, + dexPayload: ParcelFileDescriptor?, + resourcesPayload: ParcelFileDescriptor?, + assetsPayload: ParcelFileDescriptor?, + metadataJson: String?, + ) = Unit + + override fun onBuildStatus(statusJson: String?) = Unit + } + + private fun connect(reportedPackage: String = "com.example.app") = + connections.onConnected(ConnectedTarget(target, reportedPackage, runningGeneration = 1)) + + @Test + fun `a connected proxy app is held out of the freezer`() { + connections.beginSession("com.example.app", uid = 10123) + + connect() + + assertThat(hold.held).containsExactly("com.example.app") + assertThat(hold.releases).isEqualTo(0) + } + + @Test + fun `the held package comes from PackageManager, not from what the app reported`() { + connections.beginSession("com.example.app", uid = 10123) + + connect(reportedPackage = "com.attacker.elsewhere") + + // The reported name is logging-only by contract; holding it would let a caller past + // the uid gate start and pin an unrelated process by name. + assertThat(hold.held).containsExactly("com.example.app") + } + + @Test + fun `no session means nothing is held`() { + connect() + + assertThat(hold.held).isEmpty() + } + + @Test + fun `losing the proxy app drops the hold`() { + connections.beginSession("com.example.app", uid = 10123) + connect() + + connections.onDisconnected() + + assertThat(hold.releases).isEqualTo(1) + } + + @Test + fun `a relaunched proxy app is held again`() { + connections.beginSession("com.example.app", uid = 10123) + connect() + connections.onDisconnected() + + connect() + + assertThat(hold.held).containsExactly("com.example.app", "com.example.app") + assertThat(hold.releases).isEqualTo(1) + } + + @Test + fun `ending the session drops the hold, so a plain backgrounded app is cached normally`() { + connections.beginSession("com.example.app", uid = 10123) + connect() + + connections.endSession() + + assertThat(hold.releases).isEqualTo(1) + } + + @Test + fun `a second session holds the second app`() { + connections.beginSession("com.example.first", uid = 10123) + connect() + connections.endSession() + + connections.beginSession("com.example.second", uid = 10124) + connect() + + assertThat(hold.held).containsExactly("com.example.first", "com.example.second").inOrder() + } + + @Test + fun `uninstalling the hold releases it and stops driving it`() { + connections.beginSession("com.example.app", uid = 10123) + connect() + + connections.uninstallPriorityHold() + connect() + + assertThat(hold.releases).isEqualTo(1) + assertThat(hold.held).containsExactly("com.example.app") + } + + @Test + fun `a registry with no hold installed still connects`() { + val bare = ProxyAppConnections() + bare.beginSession("com.example.app", uid = 10123) + + bare.onConnected(ConnectedTarget(target, "com.example.app", runningGeneration = 1)) + bare.onDisconnected() + bare.endSession() + + assertThat(bare.target.value).isNull() + } + + /** Records what the registry asked for, so the assertions read as call sequences. */ + private class RecordingHold : ProxyAppPriorityHold { + val held = mutableListOf() + var releases = 0 + + override fun hold(packageName: String) { + held += packageName + } + + override fun release() { + releases++ + } + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppPriorityHoldTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppPriorityHoldTest.kt new file mode 100644 index 0000000000..9f3e7a197c --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppPriorityHoldTest.kt @@ -0,0 +1,111 @@ +package org.appdevforall.cotg.quickbuild.service.deploy + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * [BoundServicePriorityHold]'s bind bookkeeping, against recorded bind/unbind calls. + * + * What is being pinned is that CoGo holds exactly one binding into the proxy app at a time + * and always clears the framework's `ServiceConnection` registration - a stacked or leaked + * binding is how a "keep the app unfrozen" fix turns into a process CoGo can never let go of. + */ +class ProxyAppPriorityHoldTest { + private val bound = mutableListOf() + private var unbinds = 0 + private var bindResult = true + + private fun hold() = + BoundServicePriorityHold( + bind = { packageName -> + bound += packageName + bindResult + }, + unbind = { unbinds++ }, + ) + + @Test + fun `holding binds the named package once`() { + hold().hold("com.example.app") + + assertThat(bound).containsExactly("com.example.app") + assertThat(unbinds).isEqualTo(0) + } + + @Test + fun `re-holding the same package does not stack a second binding`() { + val hold = hold() + + hold.hold("com.example.app") + hold.hold("com.example.app") + hold.hold("com.example.app") + + assertThat(bound).containsExactly("com.example.app") + assertThat(unbinds).isEqualTo(0) + } + + @Test + fun `holding a different package releases the previous one first`() { + val hold = hold() + + hold.hold("com.example.first") + hold.hold("com.example.second") + + assertThat(bound).containsExactly("com.example.first", "com.example.second").inOrder() + assertThat(unbinds).isEqualTo(1) + } + + @Test + fun `releasing unbinds exactly once, however often it is called`() { + val hold = hold() + hold.hold("com.example.app") + + hold.release() + hold.release() + + assertThat(unbinds).isEqualTo(1) + } + + @Test + fun `releasing without a hold does not unbind`() { + hold().release() + + assertThat(unbinds).isEqualTo(0) + } + + @Test + fun `a refused bind still unbinds, so the framework registration cannot leak`() { + bindResult = false + + hold().hold("com.example.app") + + assertThat(bound).containsExactly("com.example.app") + assertThat(unbinds).isEqualTo(1) + } + + @Test + fun `a refused bind leaves nothing held, so the next hold retries`() { + val hold = hold() + bindResult = false + hold.hold("com.example.app") + bindResult = true + + hold.hold("com.example.app") + + assertThat(bound).containsExactly("com.example.app", "com.example.app") + // Only the failed attempt's cleanup; the successful hold is still live. + assertThat(unbinds).isEqualTo(1) + } + + @Test + fun `a released hold can be retaken`() { + val hold = hold() + + hold.hold("com.example.app") + hold.release() + hold.hold("com.example.app") + + assertThat(bound).containsExactly("com.example.app", "com.example.app") + assertThat(unbinds).isEqualTo(1) + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/QuickBuildHostBinderTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/QuickBuildHostBinderTest.kt new file mode 100644 index 0000000000..afe588a87d --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/QuickBuildHostBinderTest.kt @@ -0,0 +1,187 @@ +package org.appdevforall.cotg.quickbuild.service.deploy + +import android.os.IBinder +import android.os.ParcelFileDescriptor +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.quickbuild.IQuickBuildTarget +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.service.telemetry.report +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +/** + * The uid trust boundary of [QuickBuildHostService.HostBinder], against a real + * [ProxyAppConnections]. On the JVM the stubbed `Binder.getCallingUid()` reports uid 0, + * so a session begun for uid 0 stands in for the matching proxy app and any other + * `expectedUid` stands in for a foreign caller. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class QuickBuildHostBinderTest { + private val connections = ProxyAppConnections() + private val binder = QuickBuildHostService.HostBinder(connections) + + private val target = + object : IQuickBuildTarget { + override fun asBinder(): IBinder? = null + + override fun onPayload( + generation: Long, + dexPayload: ParcelFileDescriptor?, + resourcesPayload: ParcelFileDescriptor?, + assetsPayload: ParcelFileDescriptor?, + metadataJson: String?, + ) = Unit + + override fun onBuildStatus(statusJson: String?) = Unit + } + + private fun beginMatchingSession() = connections.beginSession("com.example.quickbuild", uid = 0) + + @Test + fun `every op is rejected when no session is live`() { + assertThrows(SecurityException::class.java) { binder.connect(target, "com.example.quickbuild", 0) } + assertThrows(SecurityException::class.java) { binder.reportReloaded(1, 40) } + assertThrows(SecurityException::class.java) { binder.reportCrash(1, "boom") } + assertThrows(SecurityException::class.java) { binder.disconnect("com.example.quickbuild") } + assertThat(connections.target.value).isNull() + } + + @Test + fun `a foreign uid is rejected and named in the error`() { + connections.beginSession("com.example.quickbuild", uid = 10123) + + val error = + assertThrows(SecurityException::class.java) { + binder.reportReloaded(1, 40) + } + + assertThat(error.message).contains("uid 0") + assertThat(error.message).contains("10123") + } + + @Test + fun `a matching connect registers the target at its running generation`() { + beginMatchingSession() + + binder.connect(target, "com.example.quickbuild", runningGeneration = 7) + + val connected = connections.target.value + assertThat(connected).isNotNull() + assertThat(connected!!.packageName).isEqualTo("com.example.quickbuild") + assertThat(connected.runningGeneration).isEqualTo(7) + } + + @Test + fun `connect without a target or package is rejected even from the right uid`() { + beginMatchingSession() + + assertThrows(SecurityException::class.java) { binder.connect(null, "com.example.quickbuild", 0) } + assertThrows(SecurityException::class.java) { binder.connect(target, null, 0) } + assertThat(connections.target.value).isNull() + } + + @Test + fun `reports from the session's uid reach the report flow`() = + runTest { + beginMatchingSession() + val reports = recordReports() + + binder.reportReloaded(3, 42) + binder.reportCrash(4, "NPE at MainActivity") + + assertThat(reports) + .containsExactly( + TargetReport.Reloaded(generation = 3, reloadMillis = 42), + TargetReport.Crashed(generation = 4, stackSummary = "NPE at MainActivity"), + ).inOrder() + } + + @Test + fun `a crash report without a summary reads as an unknown crash`() = + runTest { + beginMatchingSession() + val reports = recordReports() + + binder.reportCrash(5, null) + + assertThat(reports) + .containsExactly(TargetReport.Crashed(generation = 5, stackSummary = "unknown crash")) + } + + /** Collects the zero-replay report flow eagerly, so emissions land synchronously. */ + private fun kotlinx.coroutines.test.TestScope.recordReports(): List { + val seen = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + connections.reports.collect { seen += it } + } + return seen + } + + @Test + fun `disconnect clears the registered target`() { + beginMatchingSession() + binder.connect(target, "com.example.quickbuild", 0) + + binder.disconnect("com.example.quickbuild") + + assertThat(connections.target.value).isNull() + } + + @Test + fun `a death from a superseded binder leaves the live registration alone`() { + beginMatchingSession() + val dead = fakeBinder() + val live = fakeBinder() + binder.connect(targetOn(dead), "com.example.quickbuild", 0) + binder.connect(targetOn(live), "com.example.quickbuild", 0) + + connections.onDisconnected(dead) + + // The superseded process's death notification arrives after its replacement has bound. + // Clearing here would deploy the next save into NotConnected against a healthy app. + assertThat( + connections.target.value + ?.target + ?.asBinder(), + ).isSameInstanceAs(live) + } + + @Test + fun `a death from the registered binder clears the target`() { + beginMatchingSession() + val live = fakeBinder() + binder.connect(targetOn(live), "com.example.quickbuild", 0) + + connections.onDisconnected(live) + + assertThat(connections.target.value).isNull() + } + + /** + * A distinct [IBinder] identity. Only reference identity is exercised, so a reflection + * proxy is enough and avoids stubbing the whole interface against an unmocked android.jar. + */ + private fun fakeBinder(): IBinder = + java.lang.reflect.Proxy.newProxyInstance( + IBinder::class.java.classLoader, + arrayOf(IBinder::class.java), + ) { _, _, _ -> null } as IBinder + + private fun targetOn(binder: IBinder): IQuickBuildTarget = + object : IQuickBuildTarget { + override fun asBinder(): IBinder = binder + + override fun onPayload( + generation: Long, + dexPayload: ParcelFileDescriptor?, + resourcesPayload: ParcelFileDescriptor?, + assetsPayload: ParcelFileDescriptor?, + metadataJson: String?, + ) = Unit + + override fun onBuildStatus(statusJson: String?) = Unit + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/RetainedPayloadStoreTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/RetainedPayloadStoreTest.kt new file mode 100644 index 0000000000..9f2f879e6e --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/RetainedPayloadStoreTest.kt @@ -0,0 +1,112 @@ +package org.appdevforall.cotg.quickbuild.service.deploy + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** + * The retention contract the reconnect re-send stands on: what [RetainedPayloadStore.load] + * hands back is exactly what a confirmed deploy [RetainedPayloadStore.retain]ed - or null, + * never a mix. A half-readable set re-sent to the proxy app would advance it past classes it + * never received, so every corruption case must collapse to "nothing retained". + */ +class RetainedPayloadStoreTest { + @TempDir lateinit var workDir: File + + private val store by lazy { RetainedPayloadStore.forWorkDir(workDir) } + + private fun artifact( + name: String, + content: String, + ): File = File(workDir, name).apply { writeText(content) } + + @Test + fun `retain and load round-trip the payload bytes, generation and metadata`() { + val dex = artifact("built.dex", "dex-bytes") + val arsc = artifact("built.arsc", "arsc-bytes") + val assets = artifact("built-assets.zip", "assets-bytes") + + store.retain(7L, dex, arsc, assets, """{"entryActivity":"com.example.Main"}""") + val loaded = store.load()!! + + assertThat(loaded.generation).isEqualTo(7L) + assertThat(loaded.metadataJson).isEqualTo("""{"entryActivity":"com.example.Main"}""") + assertThat(loaded.dexFile!!.readText()).isEqualTo("dex-bytes") + assertThat(loaded.arscFile!!.readText()).isEqualTo("arsc-bytes") + assertThat(loaded.assetsZip!!.readText()).isEqualTo("assets-bytes") + } + + @Test + fun `retained bytes are copies - overwriting the build artifact does not change them`() { + // The executor's next build overwrites its own artifacts in place; retention that + // merely pointed at them would silently re-send the NEWER, unconfirmed bytes. + val dex = artifact("built.dex", "generation-3-bytes") + store.retain(3L, dex, null, null, "{}") + + dex.writeText("generation-4-bytes-from-a-build-that-never-deployed") + + assertThat(store.load()!!.dexFile!!.readText()).isEqualTo("generation-3-bytes") + } + + @Test + fun `a payload part the deploy did not carry loads back as null, not as a failure`() { + store.retain(2L, artifact("built.dex", "dex"), null, null, "{}") + val loaded = store.load()!! + + assertThat(loaded.dexFile).isNotNull() + assertThat(loaded.arscFile).isNull() + assertThat(loaded.assetsZip).isNull() + } + + @Test + fun `retain replaces the previous set wholesale`() { + store.retain(1L, artifact("built.dex", "old-dex"), null, artifact("a.zip", "old-assets"), "{}") + store.retain(2L, artifact("built2.dex", "new-dex"), null, null, "{}") + + val loaded = store.load()!! + assertThat(loaded.generation).isEqualTo(2L) + assertThat(loaded.dexFile!!.readText()).isEqualTo("new-dex") + // The old set's assets zip must not leak into the new set: the deploy it rode + // carried none. + assertThat(loaded.assetsZip).isNull() + } + + @Test + fun `nothing retained loads as null`() { + assertThat(store.load()).isNull() + } + + @Test + fun `corrupt metadata loads as null instead of throwing`() { + store.retain(1L, artifact("built.dex", "dex"), null, null, "{}") + File(File(workDir, "last-deployed"), "meta.json").writeText("not json {") + + assertThat(store.load()).isNull() + } + + @Test + fun `a part the metadata claims but the directory lacks makes the whole set unreadable`() { + store.retain(1L, artifact("built.dex", "dex"), null, null, "{}") + File(File(workDir, "last-deployed"), "payload.dex").delete() + + assertThat(store.load()).isNull() + } + + @Test + fun `a failed retain keeps nothing partial`() { + // The dex file vanishes before retain can copy it - the copy throws mid-swap. + val dex = File(workDir, "gone.dex") + store.retain(5L, dex, null, null, "{}") + + assertThat(store.load()).isNull() + } + + @Test + fun `clear drops the retained set`() { + store.retain(1L, artifact("built.dex", "dex"), null, null, "{}") + store.clear() + + assertThat(store.load()).isNull() + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/telemetry/E2eTimelineRecorderTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/telemetry/E2eTimelineRecorderTest.kt new file mode 100644 index 0000000000..667cc2d6dd --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/telemetry/E2eTimelineRecorderTest.kt @@ -0,0 +1,49 @@ +package org.appdevforall.cotg.quickbuild.service.telemetry + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class E2eTimelineRecorderTest { + @Test + fun `route with no compile falls back to deploySent, not trigger`() { + // E.g. a resources-only route: markCompileDone is never called, so per the + // E2eTimeline contract t1 == t2 and compileMillis measures relink+package. + val recorder = E2eTimelineRecorder(trigger = 1_000) { null } + recorder.markDeploySent(1_500) + val timeline = recorder.completed(generation = 3, reloadLive = 1_700) + assertThat(timeline.compileDone).isEqualTo(1_500) + assertThat(timeline.compileDone).isEqualTo(timeline.deploySent) + assertThat(timeline.compileMillis).isEqualTo(500) + } + + @Test + fun `markCompileDone stamps t1 ahead of the deploy`() { + val recorder = E2eTimelineRecorder(trigger = 1_000) { null } + recorder.markCompileDone(1_400) + recorder.markDeploySent(1_500) + val timeline = recorder.completed(generation = 3, reloadLive = 1_700) + assertThat(timeline.compileDone).isEqualTo(1_400) + } + + @Test + fun `empty step, span and count groups are absent, not zero-filled`() { + val recorder = E2eTimelineRecorder(trigger = 1_000) { null } + recorder.markDeploySent(1_500) + val timeline = recorder.completed(generation = 3, reloadLive = 1_700) + assertThat(timeline.steps).isNull() + assertThat(timeline.spans).isNull() + assertThat(timeline.counts).isNull() + } + + @Test + fun `recorded groups come through non-empty`() { + val recorder = E2eTimelineRecorder(trigger = 1_000) { "ext4" } + recorder.recordScan(20) + recorder.recordRelinkSteps(aapt2CompileMillis = 80, aapt2LinkMillis = 40) + val timeline = recorder.completed(generation = 3, reloadLive = 1_700) + assertThat(timeline.spans?.scanMillis).isEqualTo(20) + assertThat(timeline.steps?.aapt2CompileMillis).isEqualTo(80) + assertThat(timeline.steps?.aapt2LinkMillis).isEqualTo(40) + assertThat(timeline.scratchFsType).isEqualTo("ext4") + } +} From 041bebcdcb1ce53524cfadb6042610521e839e9c Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 22:51:39 -0700 Subject: [PATCH 2/3] =?UTF-8?q?ADFA-4128:=20qb=2006=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20binder-death=20handling,=20reconnect=20atomicity,?= =?UTF-8?q?=20deploy=20teardown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Swallowed linkToDeath failure -> a binder dead at connect is reported as an instant death and never registered, so deploys fail fast as NotConnected instead of timing out with the freezer hold kept on a dead package (tests: "a binder that is dead at connect is not left registered", "a dead binder's stale connect retry does not clobber a live registration"). - Non-atomic connect watch/registration -> registration and death watch are one @Synchronized step, and death delivery shares the lock, so the watched binder and the registered target can never disagree and a death cannot slip between link and registration (test: "a death delivered while connect is registering still clears the target"; wiring: "a reconnect moves the watch, and firing it clears the registration"). - Restart payload retained with hot-swap metadata -> a confirmed restart deploy clears the retained set instead of retaining it, so a reconnect catch-up can never hot-swap over the live restart-sensitive component and falls back to the forced rebuild (test: "a confirmed restart deploy clears the retained payload instead of retaining it"). - endSession leaving an in-flight deploy to time out -> endSession routes through onDisconnected, whose Disconnected report answers the waiter deterministically (test: "ending the session answers a deploy awaiting its verdict as Disconnected"). - Adjacent minor: disconnect() now unlinks the death watch, so no stale recipient outlives a graceful disconnect (test: "a graceful disconnect unlinks the death watch"). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W --- .../service/deploy/PayloadDeployer.kt | 12 +- .../service/deploy/ProxyAppConnections.kt | 10 +- .../service/deploy/QuickBuildHostService.kt | 60 ++++++-- .../service/deploy/RetainedPayloadStore.kt | 11 +- .../service/deploy/DeployChannelDeployTest.kt | 15 ++ .../deploy/PayloadDeployerRetentionTest.kt | 34 +++-- .../deploy/QuickBuildHostBinderTest.kt | 131 +++++++++++++++++- 7 files changed, 237 insertions(+), 36 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt index 204701d2c3..f20248b6b0 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt @@ -257,9 +257,15 @@ internal class PayloadDeployer( } else -> { - // Retained with hot-swap metadata, not this deploy's restart flag: a - // reconnect catch-up must not ask the just-relaunched app to exit again. - retention?.retain(generation, dexFile, arscFile, assets?.zip, metadata(restart = false)) + // Retain nothing, and drop what is retained: the reconnect catch-up replays + // retained bytes as a hot swap, and hot-swapping a code-bearing payload onto + // a process holding the live component this deploy restarted for recreates + // the cross-loader ClassCastException the restart existed to prevent (see + // DeployPolicy). Retaining it with the restart flag is no better - the + // re-send has no relaunch behind it, so the app would exit and stay closed. + // A below-deployed reconnect after this deploy falls back to the forced + // catch-up rebuild, which re-derives the route. + retention?.clear() // t3: the relaunched process reconnected at the deployed generation, so // the restart swap is live. Slower than a hot swap by a full process // launch. One clock read feeds both, as on the hot-swap path. diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppConnections.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppConnections.kt index 35b81e336e..aab65488cc 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppConnections.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppConnections.kt @@ -91,10 +91,12 @@ class ProxyAppConnections { fun endSession() { expectedPackage = null expectedUid = null - _target.value = null - // Nothing can deploy to the app now, so stop exempting it from the freezer: a hold - // kept past session end would cost the user battery on an app they are just running. - priorityHold?.release() + // Routed through [onDisconnected] so its Disconnected report answers a deploy still + // awaiting its verdict - the session's end settles that deploy now, instead of + // leaving it to ride out its full timeout against a target that is already gone. + // The unconditional drop also releases the freezer hold: a hold kept past session + // end would cost the user battery on an app they are just running. + onDisconnected() } /** diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/QuickBuildHostService.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/QuickBuildHostService.kt index 5998995ca1..ae09178523 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/QuickBuildHostService.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/QuickBuildHostService.kt @@ -65,10 +65,8 @@ class QuickBuildHostService : Service() { throw SecurityException("connect() with null target or packageName") } - watchForDeath(target.asBinder()) - log.info("Proxy app {} connected at generation {}", packageName, runningGeneration) - connections.onConnected(ConnectedTarget(target, packageName, runningGeneration)) + register(ConnectedTarget(target, packageName, runningGeneration)) } override fun reportReloaded( @@ -88,30 +86,70 @@ class QuickBuildHostService : Service() { } /** - * Points the death watch at [binder], dropping the watch a superseded process left. + * Registers [connection] and points the death watch at its binder, as one atomic step. * * Clearing the registration on death is what makes a deploy into a dead proxy app fail * fast as NotConnected instead of timing out on its binder. The unlink matters because a * recipient would otherwise accumulate one per reconnect, and the binder is passed on so * a late death from a superseded process cannot wipe the live registration. * - * @param binder the connecting target's binder; null only for a local (non-binder) target, - * which cannot die out from under us and so needs no watch + * One synchronized step, not watch-then-register: two racing connects could otherwise + * interleave so the registered target and the watched binder disagree, and a dead target + * would then stay registered with no death notification ever coming. [onBinderDeath] + * shares the lock for the same reason, so a death delivered while a connect is + * mid-registration lands after the registration it must clear, never before it. */ @Synchronized - private fun watchForDeath(binder: IBinder?) { - if (binder == null) return + private fun register(connection: ConnectedTarget) { + // Null only for a local (non-binder) target, which cannot die out from under us. + val binder = connection.target.asBinder() + if (binder == null) { + clearDeathWatch() + connections.onConnected(connection) + return + } + val recipient = IBinder.DeathRecipient { onBinderDeath(binder) } + try { + binder.linkToDeath(recipient, 0) + } catch (e: Exception) { + // Already dead at connect time: registering it would hold a dead target no + // death notification can ever clear, so every deploy would ride out its + // timeout with the freezer hold kept on a dead package. Report an instant + // death instead, and keep any previous watch - the superseded-binder guard + // in [ProxyAppConnections.onDisconnected] protects a still-live registration. + log.warn("Proxy app {} died before its connect completed", connection.packageName, e) + connections.onDisconnected(binder) + return + } + clearDeathWatch() + deathWatch = binder to recipient + connections.onConnected(connection) + } + + /** + * Handles a watched binder's death. Shares [register]'s lock so a death cannot be + * consumed between the link and the registration it should clear. + */ + @Synchronized + private fun onBinderDeath(binder: IBinder) { + connections.onDisconnected(binder) + } + + /** Drops the current death watch, unlinking its recipient. */ + @Synchronized + private fun clearDeathWatch() { deathWatch?.let { (previous, recipient) -> runCatching { previous.unlinkToDeath(recipient, 0) } } - val recipient = IBinder.DeathRecipient { connections.onDisconnected(binder) } - deathWatch = binder to recipient - runCatching { binder.linkToDeath(recipient, 0) } + deathWatch = null } override fun disconnect(packageName: String?) { enforceCaller("disconnect") log.info("Proxy app {} disconnected", packageName) + // Unlink too: a stale recipient would otherwise fire on the process's eventual + // death and report a disconnect against whatever is registered by then. + clearDeathWatch() connections.onDisconnected() } diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/RetainedPayloadStore.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/RetainedPayloadStore.kt index 8a85ddaf4e..e9e1ca18d8 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/RetainedPayloadStore.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/RetainedPayloadStore.kt @@ -29,8 +29,10 @@ internal class RetainedPayloadStore( * @property generation the generation the deploy claimed; a re-send replays it unchanged, * and the runtime's strictly-newer gate accepts it because the reconnected app runs * something older - * @property metadataJson metadata for the re-send; always the hot-swap variant, since a - * reconnect catch-up must not ask the just-relaunched app to persist and exit again + * @property metadataJson metadata for the re-send; always the hot-swap variant, because + * only hot-swap deploys are retained - a restart deploy [clear]s the store instead, + * since replaying its payload as a hot swap would land on the live restart-sensitive + * component the restart existed to protect * @property dexFile the retained classes, or null when the deploy carried none * @property arscFile the retained resource APK, or null when the deploy carried none * @property assetsZip the retained changed-assets zip, or null when the deploy carried none @@ -117,8 +119,9 @@ internal class RetainedPayloadStore( } /** - * Drops the retained set. Call whenever the baseline changes: the old baseline's bytes - * must never be replayed onto a new one. + * Drops the retained set. Call whenever the baseline changes - the old baseline's bytes + * must never be replayed onto a new one - and after a confirmed restart deploy, whose + * generation supersedes the retained one but must never be replayed as a hot swap. */ fun clear() { dir.deleteRecursively() diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannelDeployTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannelDeployTest.kt index fb59367865..ca083d0f5b 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannelDeployTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannelDeployTest.kt @@ -122,6 +122,21 @@ class DeployChannelDeployTest { assertThat(deploy.await()).isEqualTo(DeployResult.Disconnected) } + @Test + fun `ending the session answers a deploy awaiting its verdict as Disconnected`() = + runTest { + val target = ScriptedTarget() + connect(target) + + val deploy = async { channel.deploy(3, null, null, null, "{}") } + runCurrent() + connections.endSession() + + // Session teardown settles the in-flight deploy now; without the Disconnected + // report it would ride out the full 5 s timeout and read as TimedOut. + assertThat(deploy.await()).isEqualTo(DeployResult.Disconnected) + } + @Test fun `a binder failure during onPayload reports Failed naming the binder`() = runTest { diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerRetentionTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerRetentionTest.kt index e598a81789..60a8a2140c 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerRetentionTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerRetentionTest.kt @@ -16,9 +16,10 @@ import org.junit.jupiter.api.io.TempDir import java.io.File /** - * Retention side of [PayloadDeployer] (concurrency.md rules 3-4): a deploy the proxy app - * confirmed leaves its bytes in the [RetainedPayloadStore] for the reconnect re-send, and an - * unconfirmed one leaves the store exactly as it was. + * Retention side of [PayloadDeployer] (concurrency.md rules 3-4): a confirmed hot-swap + * deploy leaves its bytes in the [RetainedPayloadStore] for the reconnect re-send, an + * unconfirmed one leaves the store exactly as it was, and a confirmed restart deploy clears + * it - its payload must never be replayed as a hot swap. */ class PayloadDeployerRetentionTest { @TempDir lateinit var workDir: File @@ -120,14 +121,23 @@ class PayloadDeployerRetentionTest { } @Test - fun `a confirmed restart deploy retains hot-swap metadata, not the restart flag`() = + fun `a confirmed restart deploy clears the retained payload instead of retaining it`() = runTest { - deploy.result = DeployResult.Reloaded(40) + val deployer = deployer() + deployer.deploy( + DeployDecision.Recreate, + artifact("built.dex", "gen-1-dex"), + null, + null, + loopStartedAt = 0, + recorder = recorder(), + ) + assertThat(store.load()).isNotNull() val outcome = - deployer().deploy( + deployer.deploy( DeployDecision.Restart(ComponentKind.SERVICE, "com.example.app.SyncService"), - artifact("built.dex", "dex-bytes"), + artifact("built.dex", "gen-2-dex"), null, null, loopStartedAt = 0, @@ -135,11 +145,11 @@ class PayloadDeployerRetentionTest { ) assertThat(outcome).isInstanceOf(BuildOutcome.Success::class.java) - val retained = store.load()!! - // The deploy itself carried restart=true; the re-send must not, or a reconnect - // catch-up would ask the just-relaunched app to persist and exit again. - assertThat(retained.metadataJson).doesNotContain("restart") - assertThat(retained.dexFile!!.readText()).isEqualTo("dex-bytes") + // A reconnect catch-up replays retained bytes as a hot swap, which would land on + // the live service this deploy restarted for and redefine its classes under it - + // the cross-loader CCE the restart existed to prevent. Nothing may stay retained; + // a below-deployed reconnect falls back to the forced catch-up rebuild. + assertThat(store.load()).isNull() } @Test diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/QuickBuildHostBinderTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/QuickBuildHostBinderTest.kt index afe588a87d..4444b16854 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/QuickBuildHostBinderTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/QuickBuildHostBinderTest.kt @@ -2,6 +2,7 @@ package org.appdevforall.cotg.quickbuild.service.deploy import android.os.IBinder import android.os.ParcelFileDescriptor +import android.os.RemoteException import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.quickbuild.IQuickBuildTarget import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -14,8 +15,9 @@ import org.junit.jupiter.api.Test /** * The uid trust boundary of [QuickBuildHostService.HostBinder], against a real - * [ProxyAppConnections]. On the JVM the stubbed `Binder.getCallingUid()` reports uid 0, - * so a session begun for uid 0 stands in for the matching proxy app and any other + * [ProxyAppConnections], and the death-watch wiring that keeps the registered target and + * the watched binder in step. On the JVM the stubbed `Binder.getCallingUid()` reports + * uid 0, so a session begun for uid 0 stands in for the matching proxy app and any other * `expectedUid` stands in for a foreign caller. */ @OptIn(ExperimentalCoroutinesApi::class) @@ -160,6 +162,131 @@ class QuickBuildHostBinderTest { assertThat(connections.target.value).isNull() } + @Test + fun `a binder that is dead at connect is not left registered`() { + beginMatchingSession() + val dead = WatchableBinder(onLink = { _, _ -> throw RemoteException("already dead") }) + + binder.connect(targetOn(dead.binder), "com.example.quickbuild", 0) + + // A dead target no death notification can ever clear would turn every deploy into + // a full timeout; the failed link must fail fast to no registration instead. + assertThat(connections.target.value).isNull() + } + + @Test + fun `a dead binder's stale connect retry does not clobber a live registration`() { + beginMatchingSession() + val live = WatchableBinder() + binder.connect(targetOn(live.binder), "com.example.quickbuild", 0) + val dead = WatchableBinder(onLink = { _, _ -> throw RemoteException("already dead") }) + + binder.connect(targetOn(dead.binder), "com.example.quickbuild", 0) + + // The superseded process's retry lost the race to the fresh process's bind; the + // fresh registration must survive it, and stay watched. + assertThat( + connections.target.value + ?.target + ?.asBinder(), + ).isSameInstanceAs(live.binder) + assertThat(live.watching()).hasSize(1) + } + + @Test + fun `a death delivered while connect is registering still clears the target`() { + beginMatchingSession() + // linkToDeath delivers the death on another thread immediately, the way a proxy app + // crashing right after its connect() call does. The helper returns once that + // delivery has either completed or parked against the binder's registration lock, + // so both orderings are exercised deterministically rather than raced. + var death: Thread? = null + val dying = + WatchableBinder( + onLink = { _, recipient -> + val delivery = Thread { recipient.binderDied() }.also { it.start() } + death = delivery + while (delivery.state != Thread.State.TERMINATED && delivery.state != Thread.State.BLOCKED) { + Thread.sleep(1) + } + }, + ) + + binder.connect(targetOn(dying.binder), "com.example.quickbuild", 0) + death!!.join(5_000) + + // Registration and death watch are one atomic step, so the death lands after the + // registration and clears it - a dead target must never stay registered. + assertThat(connections.target.value).isNull() + } + + @Test + fun `a reconnect moves the watch, and firing it clears the registration`() { + beginMatchingSession() + val first = WatchableBinder() + val second = WatchableBinder() + binder.connect(targetOn(first.binder), "com.example.quickbuild", 0) + binder.connect(targetOn(second.binder), "com.example.quickbuild", 1) + + assertThat(first.watching()).isEmpty() + val recipient = second.watching().single() + + recipient.binderDied() + + assertThat(connections.target.value).isNull() + } + + @Test + fun `a graceful disconnect unlinks the death watch`() { + beginMatchingSession() + val live = WatchableBinder() + binder.connect(targetOn(live.binder), "com.example.quickbuild", 0) + + binder.disconnect("com.example.quickbuild") + + // No stale recipient stays linked to fire on the process's eventual death. + assertThat(live.watching()).isEmpty() + } + + /** + * An [IBinder] that records link/unlink traffic and can script [IBinder.linkToDeath], + * so the death-watch wiring is exercised for real. Only the two death-watch methods + * are live; everything else no-ops through the reflection proxy. + */ + private class WatchableBinder( + private val onLink: (WatchableBinder, IBinder.DeathRecipient) -> Unit = { _, _ -> }, + ) { + val linked = mutableListOf() + val unlinked = mutableListOf() + + val binder: IBinder = + java.lang.reflect.Proxy.newProxyInstance( + IBinder::class.java.classLoader, + arrayOf(IBinder::class.java), + ) { _, method, args -> + when (method.name) { + "linkToDeath" -> { + val recipient = args!![0] as IBinder.DeathRecipient + onLink(this, recipient) + linked += recipient + null + } + + "unlinkToDeath" -> { + unlinked += args!![0] as IBinder.DeathRecipient + true + } + + else -> { + null + } + } + } as IBinder + + /** The recipients still linked, in link order. */ + fun watching(): List = linked.filterNot { it in unlinked } + } + /** * A distinct [IBinder] identity. Only reference identity is exercised, so a reflection * proxy is enough and avoids stubbing the whole interface against an unmocked android.jar. From 951df8e9c289b33bfa93d38377730ed0bf81d7cd Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 26 Aug 2026 23:43:45 -0700 Subject: [PATCH 3/3] ADFA-4128 (6/11): address CodeRabbit review - F1718-2 stop advertising an E2eTimeline.parse that does not exist - F1718-6 stop the metrics helper swallowing fatals and cancellation - F1718-7 drop the dead telemetry.report import from LiveReloadOrchestratorTest - F1718-8 cover queueMillis in the HostSpans per-field test Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FstXxJ5cwWPcvmhZ9vJgJ7 --- .../quickbuild/domain/telemetry/README.md | 2 +- .../service/telemetry/MetricsReporting.kt | 13 ++++++ .../reload/LiveReloadOrchestratorTest.kt | 1 - .../domain/telemetry/E2eTimelineGroupsTest.kt | 1 + .../service/telemetry/MetricsReportingTest.kt | 46 +++++++++++++++++++ 5 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/telemetry/MetricsReportingTest.kt diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/README.md b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/README.md index 1e4f1297c6..768bff161f 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/README.md +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/README.md @@ -4,5 +4,5 @@ Pure-JVM types for measuring the live-reload loop: one timeline per edit and one | File | Purpose | | --- | --- | -| [`E2eTimeline.kt`](E2eTimeline.kt) | One generation's four-stamp timeline plus `StepTimings`, `HostSpans`, `BuildCounts`; derives stage deltas and the grep-stable log `format`/`parse`. | +| [`E2eTimeline.kt`](E2eTimeline.kt) | One generation's four-stamp timeline plus `StepTimings`, `HostSpans`, `BuildCounts`; derives stage deltas and the grep-stable log `format`. Nothing here parses that line back - the benchmark harness's own Python parser does. | | [`QuickBuildMetricsSink.kt`](QuickBuildMetricsSink.kt) | Interface for recording session/build/invalidation/reload/rebuild stats; must be cheap and never throw. Includes a `Noop` implementation. | diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/MetricsReporting.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/MetricsReporting.kt index fcfef4d477..997bf9992d 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/MetricsReporting.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/MetricsReporting.kt @@ -1,5 +1,6 @@ package org.appdevforall.cotg.quickbuild.service.telemetry +import kotlinx.coroutines.CancellationException import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -13,12 +14,24 @@ internal val metricsLog: Logger = LoggerFactory.getLogger("QB-Metrics") * Every metrics call in this package goes through here rather than relying on each class * to remember its own try/catch. * + * Two kinds of throwable are NOT swallowed - see the catch clauses for why. + * * @param block the metrics call; must be side-effect-free beyond reporting, since a * partial run is swallowed and never retried */ internal inline fun report(block: () -> Unit) { try { block() + } catch (e: CancellationException) { + // This helper is inline, so a suspending call written inside the lambda compiles. + // Swallowing its cancellation would run the caller's coroutine on past its own + // cancellation, and nothing warns the author - there is no compile error to hit. + throw e + } catch (e: VirtualMachineError) { + // The VM is out of a resource the rest of the build also needs. Logging it formats a + // message and walks a stack trace, and the build then carries on reporting Success - + // hiding the failure on exactly the low-spec devices this product exists for. + throw e } catch (e: Throwable) { metricsLog.warn("Quick Build metrics sink failed", e) } diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestratorTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestratorTest.kt index f1e1a76a91..418daeda20 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestratorTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestratorTest.kt @@ -11,7 +11,6 @@ import org.appdevforall.cotg.quickbuild.domain.ChangedFiles import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute import org.appdevforall.cotg.quickbuild.domain.classify.ChangeClassifier import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason -import org.appdevforall.cotg.quickbuild.service.telemetry.report import org.junit.jupiter.api.Test import java.io.File diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimelineGroupsTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimelineGroupsTest.kt index afff203593..ae1c503c9c 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimelineGroupsTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimelineGroupsTest.kt @@ -57,6 +57,7 @@ class E2eTimelineGroupsTest { fun `each HostSpans field alone makes the group non-empty and counts toward the total`() { val singles = listOf( + E2eTimeline.HostSpans(queueMillis = 7), E2eTimeline.HostSpans(scanMillis = 7), E2eTimeline.HostSpans(compileRpcMillis = 7), E2eTimeline.HostSpans(policyMillis = 7), diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/telemetry/MetricsReportingTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/telemetry/MetricsReportingTest.kt new file mode 100644 index 0000000000..9674237808 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/telemetry/MetricsReportingTest.kt @@ -0,0 +1,46 @@ +package org.appdevforall.cotg.quickbuild.service.telemetry + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CancellationException +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +/** + * What [report] may and may not swallow. A metrics sink must never break a build, but two + * throwables are not the sink's failure to absorb: a [VirtualMachineError] means the whole + * process is out of a resource, and a [CancellationException] belongs to the caller's + * coroutine - [report] is `inline`, so a suspending call written inside the lambda compiles + * with no warning that its cancellation would be eaten here. + */ +class MetricsReportingTest { + @Test + fun `an ordinary sink failure is swallowed`() { + var ran = false + + report { + ran = true + throw IllegalStateException("sink is down") + } + + // No throw: the build carries on, which is the whole point of the helper. + assertThat(ran).isTrue() + } + + @Test + fun `a VirtualMachineError is not swallowed`() { + val fatal = OutOfMemoryError("no heap left for the metrics buffer") + + val thrown = assertThrows { report { throw fatal } } + + assertThat(thrown).isSameInstanceAs(fatal) + } + + @Test + fun `a CancellationException reaches the caller's coroutine`() { + val cancelled = CancellationException("session torn down mid-report") + + val thrown = assertThrows { report { throw cancelled } } + + assertThat(thrown).isSameInstanceAs(cancelled) + } +}