From c20a3578c3c182fe5f73c889834281644823f760 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 15:03:32 -0700 Subject: [PATCH 01/11] =?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 41a1e8393a84c359eeb2801621f2f237aaa82cc7 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 22:51:39 -0700 Subject: [PATCH 02/11] =?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 bd298f64b110c48d8778cd052bae343bf515b46c Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 26 Aug 2026 23:43:45 -0700 Subject: [PATCH 03/11] 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) + } +} From 3defae4551164cd59e23a9a1b884f349e680ad17 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Sat, 29 Aug 2026 16:11:39 -0700 Subject: [PATCH 04/11] ADFA-4128 (6/11): drop the disconnect binder method and its tests The runtime only ever disconnected by process death, which ProxyAppConnections.onDisconnected already handles; the AIDL method went in the qb-04 followup. Asked for in review on #1718. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Xsc7AMGBVyEMfrwpZX87iC --- .../service/deploy/QuickBuildHostService.kt | 9 -------- .../deploy/QuickBuildHostBinderTest.kt | 23 ------------------- 2 files changed, 32 deletions(-) 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 ae09178523..92e5c0967c 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 @@ -144,15 +144,6 @@ class QuickBuildHostService : Service() { 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() - } - /** * Throws unless the caller is the proxy app the live session accepts. * 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 4444b16854..f2cc662232 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 @@ -47,7 +47,6 @@ class QuickBuildHostBinderTest { 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() } @@ -122,16 +121,6 @@ class QuickBuildHostBinderTest { 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() @@ -236,18 +225,6 @@ class QuickBuildHostBinderTest { 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 From 7a895b3e51586e2d7168ba74d5e28d71f1c6fa6e Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 31 Aug 2026 18:48:06 -0700 Subject: [PATCH 05/11] ADFA-4128: qb-06 review fixes - latching union on failed builds, t3 before retention, broad binder catch, doc corrections Akash's 08-31 review of #1718, all seven items: - LiveReloadOrchestrator: the failed-build merge goes through unionPendingLocked, so an Unknown batch collapsing over a mid-build invalidating edit latches the Gradle verdict instead of erasing it. Test pins the collapse (verified red: the manifest edit rode the fast daemon path). - PayloadDeployer: t3 is read before the retention copy (hot swap) and the retention clear (restart), keeping post-deploy bookkeeping out of the timed save-to-live span. Ordering tests verified red against the old order at both sites. - DeployChannel: the payload send rethrows CancellationException and degrades any other exception to DeployResult.Failed, mirroring notifyBuildStatus's binder rationale; escape would also dodge the not-connected escalation. Test verified red. - deploy/README: table gains ProxyAppPriorityHold and RetainedPayloadStore. - ComponentInfo: header rewritten to the declares-one-always-restarts rule; supertypes marked carried-but-unread. - ClassHeader: KDoc aligned with the README's "currently unused". - E2eTimelineRecorder: false class-header-parse claim dropped. quickbuild:core tests green (both flavors). Also: plain-language pass over the comments added by these fixes Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01STCsdMzx9daNBcqMN424Ci --- .../quickbuild/domain/reload/ClassHeader.kt | 3 +- .../quickbuild/domain/reload/ComponentInfo.kt | 16 ++-- .../domain/reload/LiveReloadOrchestrator.kt | 5 +- .../service/deploy/DeployChannel.kt | 13 +++ .../service/deploy/PayloadDeployer.kt | 13 +-- .../cotg/quickbuild/service/deploy/README.md | 2 + .../service/telemetry/E2eTimelineRecorder.kt | 3 +- .../reload/LiveReloadOrchestratorTest.kt | 27 ++++++ .../service/deploy/DeployChannelDeployTest.kt | 15 ++++ .../deploy/PayloadDeployerRetentionTest.kt | 86 +++++++++++++++++++ 10 files changed, 168 insertions(+), 15 deletions(-) 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 index c32d6d9fcc..1114a93611 100644 --- 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 @@ -4,7 +4,8 @@ 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. + * interfaces. Currently unused: [DeployPolicy] keeps no supertype index. Kept for a planned + * deploy mode that redefines classes in place and will need the hierarchy. * * 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 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 index 03558e4183..834c1188be 100644 --- 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 @@ -3,18 +3,20 @@ 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. + * The restart rule referred to throughout this file is [DeployPolicy]'s: a manifest that + * declares even one restart-sensitive component makes EVERY deploy a process restart. The + * policy does not look at what the compile touched, so there is no per-build set of + * affected classes to compute; "outside the restart rule" below means the kind never makes + * a deploy restart-sensitive on its own. */ enum class ComponentKind { - /** An ``; outside the restart closure, since recreate already refreshes it. */ + /** An ``; outside the restart rule, 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. */ + /** A ``; outside the restart rule, being instantiated fresh per delivery. */ RECEIVER, /** A ``; like a service, a live instance forces a process restart. */ @@ -83,7 +85,9 @@ fun ComponentInfo.isRestartSensitive(): Boolean = kind in RESTART_SENSITIVE_KIND * @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. + * class headers at proxy app build time. Carried but currently unread: [DeployPolicy] + * keeps no supertype index. Kept for a planned deploy mode that redefines classes in + * place and will need the hierarchy. */ data class ComponentInfo( val kind: ComponentKind, 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 index 20ab908c58..505e064137 100644 --- 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 @@ -712,7 +712,10 @@ class LiveReloadOrchestrator( else -> { val newSavesArrivedMidBuild = !pending.isEmpty || pendingForced - pending = flight.batch + pending + // unionPendingLocked, not bare plus: if flight.batch is Unknown (built off an + // untrusted baseline), plus would collapse the union to Unknown and lose the + // full-Gradle-build verdict of a manifest or gradle edit that landed mid-build. + pending = unionPendingLocked(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 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 index 2434fa2a2e..2d6452ce69 100644 --- 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 @@ -2,6 +2,7 @@ package org.appdevforall.cotg.quickbuild.service.deploy import android.os.ParcelFileDescriptor import android.os.RemoteException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -193,6 +194,18 @@ class DeployChannel( verdict.cancel() log.error("Deploy of generation {} could not open a payload fd", generation, e) return@coroutineScope DeployResult.Failed("Cannot open payload: ${e.message}") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // A binder proxy can throw more than RemoteException (see + // notifyBuildStatus below). The throw must come back as a failed + // deploy: escaping would crash the deploy loop, and it would bypass + // the orchestrator's repeated-failure tally + // (LiveReloadOrchestrator.recordFailureLocked), which only sees + // failures returned as results. + verdict.cancel() + log.error("Deploy of generation {} failed past the binder surface", generation, e) + return@coroutineScope DeployResult.Failed("Deploy failed: ${e.message}") } when (val report = verdict.await()) { 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 f20248b6b0..7bd0720162 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 @@ -126,13 +126,15 @@ internal class PayloadDeployer( 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. + // Read BEFORE the retention copy below: the copy is post-deploy + // bookkeeping, not save-to-live latency, so it stays out of the timed span. val liveAt = clock() + // 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)) reportTimeline(recorder.completed(generation, liveAt)) BuildOutcome.Success(generation, liveAt - loopStartedAt) } @@ -265,11 +267,12 @@ internal class PayloadDeployer( // 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. + // launch. One clock read feeds both, as on the hot-swap path - and read + // BEFORE the retention drop below, which is bookkeeping outside the span. val liveAt = clock() + retention?.clear() reportTimeline(recorder.completed(generation, liveAt)) BuildOutcome.Success(generation, liveAt - loopStartedAt, restarted = true) } 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 index 88f18625c2..070e098d44 100644 --- 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 @@ -9,3 +9,5 @@ This folder holds the deploy side of the service layer: the exported host servic | [`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. | +| [`ProxyAppPriorityHold.kt`](ProxyAppPriorityHold.kt) | Keeps the connected proxy app out of the cached-app freezer by binding its keep-alive service; `BoundServicePriorityHold` is the on-device implementation. | +| [`RetainedPayloadStore.kt`](RetainedPayloadStore.kt) | Retains the last confirmed deploy's bytes so a reconnect below the deployed generation can be answered by a re-send instead of a forced rebuild. | 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 index 24a04a2393..306a794520 100644 --- 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 @@ -73,8 +73,7 @@ internal class E2eTimelineRecorder( } /** - * Records the hot-swap-versus-restart decision, including the class-header parses it - * needs. + * Records the hot-swap-versus-restart decision. * * @param millis host-observed span */ 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 418daeda20..5b621dc7a1 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 @@ -2202,4 +2202,31 @@ class LiveReloadOrchestratorTest { assertThat(executor.requests.last().route).isEqualTo(BuildRoute.CodeAndResources) assertThat(events.filterIsInstance()).isEmpty() } + + @Test + fun `a manifest edit landing mid-build survives a failed build collapsing the set to Unknown`() = + runTest { + // A failed build hands its batch back into pending. When that batch is Unknown + // (built off an untrusted baseline) and a manifest edit landed mid-build, a bare + // plus collapses the union to Unknown and loses the manifest edit's full-build + // verdict - the next save then takes the fast daemon path with a change the + // daemon cannot absorb. + val executor = GatedExecutor() + val events = mutableListOf() + val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it } + + orchestrator.onBaselineUntrusted() + orchestrator.onFilesChanged(known(srcA)) + runCurrent() + orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml")) + runCurrent() + executor.finish(0, compileError()) + runCurrent() + orchestrator.onFilesChanged(known(srcB)) + runCurrent() + + assertThat(executor.requests).hasSize(1) + assertThat(events.filterIsInstance()) + .containsExactly(OrchestratorEvent.InvalidationRequired(InvalidationReason.MANIFEST_CHANGED)) + } } 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 ca083d0f5b..2a117f38a2 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 @@ -159,6 +159,21 @@ class DeployChannelDeployTest { assertThat((result as DeployResult.Failed).message).contains("Cannot open payload") } + @Test + fun `a payload throw beyond RemoteException degrades to Failed instead of escaping`() = + runTest { + // A binder proxy can throw more than RemoteException (the reason notifyBuildStatus + // catches Exception). An escape here would crash the deploy loop instead of + // failing the one deploy, and would bypass the orchestrator's repeated-failure + // tally, which only sees failures returned as results. + connect(ScriptedTarget(onPayloadThrow = { IllegalStateException("binder went weird") })) + + val result = channel.deploy(3, null, null, null, "{}") + + assertThat(result).isInstanceOf(DeployResult.Failed::class.java) + assertThat((result as DeployResult.Failed).message).contains("Deploy failed") + } + @Test fun `a proxy app that never answers times out with the configured timeout`() = 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 60a8a2140c..6deaa2bc57 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 @@ -152,6 +152,92 @@ class PayloadDeployerRetentionTest { assertThat(store.load()).isNull() } + @Test + fun `the retention copy runs outside the timed save-to-live span`() = + runTest { + // The live-at clock read (t3) happens before retain(): the copy is post-deploy + // bookkeeping, and a clock read after it would bill the copy - which scales with + // app size, not edit size - to every successful deploy's save-to-live latency. + var clockReadAfterRetention = false + val 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 = { + if (store.load() != null) clockReadAfterRetention = true + 1_000 + }, + reportTimeline = {}, + retention = store, + ) + + deployer.deploy( + DeployDecision.Recreate, + artifact("built.dex", "dex-bytes"), + null, + null, + loopStartedAt = 0, + recorder = recorder(), + ) + + assertThat(store.load()).isNotNull() + assertThat(clockReadAfterRetention).isFalse() + } + + @Test + fun `the restart path's retention clear also runs outside the timed span`() = + runTest { + // Same ordering rule as the hot-swap path: the clear is bookkeeping, so t3 is + // read while the previous deploy's bytes are still retained. + var tracking = false + var clockReadAfterClear = false + val 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 = { + if (tracking && store.load() == null) clockReadAfterClear = true + 1_000 + }, + reportTimeline = {}, + retention = store, + ) + deployer.deploy( + DeployDecision.Recreate, + artifact("built.dex", "gen-1-dex"), + null, + null, + loopStartedAt = 0, + recorder = recorder(), + ) + assertThat(store.load()).isNotNull() + tracking = true + + deployer.deploy( + DeployDecision.Restart(ComponentKind.SERVICE, "com.example.app.SyncService"), + artifact("built.dex", "gen-2-dex"), + null, + null, + loopStartedAt = 0, + recorder = recorder(), + ) + + assertThat(store.load()).isNull() + assertThat(clockReadAfterClear).isFalse() + } + @Test fun `a restart deploy whose relaunch never comes back retains nothing`() = runTest { From 9eec2cb90c63627682ee42111cb02d23b05a878c Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 2 Sep 2026 17:08:15 -0700 Subject: [PATCH 06/11] ADFA-4128: 0902 review round on quickbuild:core deploy/reload Non-blocking items from Akash's 2 September round; the PR itself is approved. - Two sibling docs still stated a "restart closure" rule the policy does not implement. Both now say what DeployPolicy does - a declared restart-sensitive component restarts every code-bearing deploy - and the stale-helpers notice says which deploys can still reach it. https://github.com/appdevforall/CodeOnTheGo/pull/1718#discussion_r3914115497 - An ask is no longer reported as AWAITS_DEPLOY without checking that a deploy is actually still coming. Every path that declines to start a build leaves the work queued, so this changes no outcome today; it stops a future early return from dropping the tap silently instead. https://github.com/appdevforall/CodeOnTheGo/pull/1718#discussion_r3914112866 - installPriorityHold releases the hold it replaces, which is what its KDoc already promised. https://github.com/appdevforall/CodeOnTheGo/pull/1718#discussion_r3914112905 - Dropped an unused import ktlint's substring match cannot see. https://github.com/appdevforall/CodeOnTheGo/pull/1718#discussion_r3914112882 - RetainedPayloadStore's KDoc claims the last deploy's own parts rather than a cumulative set, which is what retain writes. https://github.com/appdevforall/CodeOnTheGo/pull/1718#discussion_r3914112860 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../quickbuild/domain/reload/ComponentInfo.kt | 8 +++---- .../domain/reload/LiveReloadOrchestrator.kt | 21 +++++++++++++++++-- .../domain/session/QuickBuildNotice.kt | 8 ++++--- .../service/deploy/DeployChannel.kt | 1 - .../service/deploy/ProxyAppConnections.kt | 3 +++ .../service/deploy/RetainedPayloadStore.kt | 8 ++++--- 6 files changed, 36 insertions(+), 13 deletions(-) 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 index 834c1188be..560a322708 100644 --- 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 @@ -27,12 +27,12 @@ enum class ComponentKind { } /** - * The kinds whose live instance a loader swap cannot update, so a recompile inside their - * restart closure forces a process restart ([DeployPolicy]). + * The kinds whose live instance a loader swap cannot update, so an app that DECLARES one + * restarts the process on every code-bearing deploy ([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 + * [org.appdevforall.cotg.quickbuild.domain.session.QuickBuildNotice.STALE_COMPONENT_HELPERS] warning that fires when one of these + * exists and the deploy hot-swapped anyway. Both read it through [isRestartSensitive], which * also applies the [COGO_INJECTED_COMPONENTS] exemption. */ val RESTART_SENSITIVE_KINDS: Set = 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 index 505e064137..f532638a9d 100644 --- 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 @@ -219,8 +219,8 @@ class LiveReloadOrchestrator( // names why, so only a forced blind rebuild repairs it. markBatchArrivalLocked() pendingForced = true - outcome = LiveReloadRequestOutcome.AWAITS_DEPLOY maybeStartBuildLocked(events) + outcome = deployAnsweringOutcomeLocked() } !pending.isEmpty -> { @@ -228,8 +228,8 @@ class LiveReloadOrchestrator( // would be; the deploy answers the tap. markBatchArrivalLocked() pendingUserInitiated = true - outcome = LiveReloadRequestOutcome.AWAITS_DEPLOY maybeStartBuildLocked(events) + outcome = deployAnsweringOutcomeLocked() } expectChanges -> { @@ -254,6 +254,23 @@ class LiveReloadOrchestrator( return outcome } + /** + * Whether a deploy is still coming for the ask just recorded, read after the build attempt. + * + * [maybeStartBuildLocked] can decline to start one - a build already in flight, a proxy app + * rebuild absorbing the set, a route the live reload path hands off as an invalidation - and + * the outcome was AWAITS_DEPLOY regardless, which promises the caller a deploy that may never + * come. Every one of those declines leaves the work queued for a later build, so the promise + * holds today; asserting it here is what keeps a future early return from silently dropping + * the tap instead. + */ + private fun deployAnsweringOutcomeLocked(): LiveReloadRequestOutcome = + if (inFlight != null || !pending.isEmpty || pendingForced) { + LiveReloadRequestOutcome.AWAITS_DEPLOY + } else { + LiveReloadRequestOutcome.SWITCH_NOW + } + /** * 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. 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 index 8227196cad..84d568d3b0 100644 --- 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 @@ -27,9 +27,11 @@ enum class QuickBuildNotice { * `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. + * Not a failure: the deploy worked and the recreated activity runs the new code. A declared + * restart-sensitive component normally makes every code-bearing deploy a restart, so this + * only fires where one did not - a baseline whose runtime cannot honour the restart request. + * The stale copies are then the helper classes the live component calls, which no restart + * decision inspects. */ STALE_COMPONENT_HELPERS, 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 index 2d6452ce69..e7c3e88826 100644 --- 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 @@ -8,7 +8,6 @@ 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 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 aab65488cc..d4957cfa18 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 @@ -61,6 +61,9 @@ class ProxyAppConnections { * @param hold the hold to take on connect and drop on disconnect or session end */ fun installPriorityHold(hold: ProxyAppPriorityHold) { + // The KDoc promises replacement, so the one being replaced has to be dropped: keeping it + // would hold a gone proxy app out of the freezer for the rest of the process's life. + if (priorityHold !== hold) priorityHold?.release() priorityHold = hold } 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 e9e1ca18d8..e2326f069e 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 @@ -10,9 +10,11 @@ import java.io.File * 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. + * What is kept is the LAST deploy's own parts, not a cumulative union: a re-send therefore + * brings a same-baseline app current only where that deploy's parts already covered it, and + * whether every consumer needs more than that is the reconnect re-send's question, not this + * store's. 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 From 651d550bf67ad1ce0ab0b51b665c0af97c989801 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:26:05 -0700 Subject: [PATCH 07/11] ADFA-4128: absorb a mid-rebuild save only when its mtime proves it predates the rebuild A filesystem that truncates mtimes to whole seconds can stamp a save made just after a proxy app rebuild started with an mtime just before it. The echo split then folded it into the absorbed set and onBaselineReset dropped it: the edit never built and nothing said so. An mtime on a whole-second boundary is now taken as truncated and must predate the rebuild start by 2 s (the FAT step) to absorb. A flat margin on every mtime would instead strand a genuinely pre-start save on every filesystem, because the watcher's debounce (150 ms quiet, 1 s cap) puts such a save's mtime within a second of the start; four of the existing F4 echo tests go red under it. A file the rebuild already holds keeps the exact cutoff, since its arrival is the echo the split exists to absorb. Review thread: https://github.com/appdevforall/CodeOnTheGo/pull/1718#discussion_r3914112870 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../domain/reload/LiveReloadOrchestrator.kt | 32 ++++++++++++++++++- .../reload/LiveReloadOrchestratorTest.kt | 29 +++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) 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 index f532638a9d..711b1d64d8 100644 --- 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 @@ -493,6 +493,21 @@ class LiveReloadOrchestrator( * start, files with no readable mtime (nothing proves they predate the read), removals (no * mtime left to date them), and [ChangedFiles.Unknown]. * + * A filesystem may truncate mtimes to whole seconds (ext4 without extended timestamps, + * two seconds on FAT), so a save landing just after the start can report an mtime just + * before it. An mtime on a whole-second boundary is taken as truncated, and a file with + * one is absorbed only when it predates the start by [MTIME_GRANULARITY_MARGIN_MILLIS]: + * on a coarse filesystem that errs toward one extra build rather than a silently dropped + * edit, and on a precise one it misfires on the one save in a thousand that lands on an + * exact second, at the same cost. Applying the margin to every mtime instead would strand + * a genuinely pre-start save on every filesystem: the watcher's debounce (150 ms quiet, + * 1 s cap) puts such a save's mtime within a second of the start. + * + * A file the rebuild already holds keeps the exact cutoff even when truncated: its + * arrival is the echo of the save the rebuild is absorbing, and stranding that echo is + * the spurious invalidation this split exists to prevent. The residual is a re-edit of a + * held file within the margin after the start on a coarse filesystem, which absorbs. + * * 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. @@ -506,7 +521,16 @@ class LiveReloadOrchestrator( if (changes !is ChangedFiles.Known) return changes val absorbed = changes.files.filterTo(mutableSetOf()) { file -> - fileLastModified(file) in 1..absorptionStartedAtMillis + val mtime = fileLastModified(file) + val heldAlready = held !is ChangedFiles.Known || file in held.files + val truncated = mtime % 1_000L == 0L + val cutoff = + if (heldAlready || !truncated) { + absorptionStartedAtMillis + } else { + absorptionStartedAtMillis - MTIME_GRANULARITY_MARGIN_MILLIS + } + mtime in 1..cutoff } if (absorbed.isEmpty()) return changes awaitingAbsorption = held + ChangedFiles.Known(absorbed) @@ -863,6 +887,12 @@ class LiveReloadOrchestrator( * full Gradle build. */ const val ESCALATE_AFTER_IDENTICAL_FAILURES = 2 + + /** + * Coarsest mtime granularity a project tree is expected to sit on: FAT stamps mtimes in + * 2 s steps, ext4 without extended timestamps in 1 s. See [absorbEchoesLocked]. + */ + const val MTIME_GRANULARITY_MARGIN_MILLIS = 2_000L } } 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 5b621dc7a1..2f409efe48 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 @@ -726,6 +726,35 @@ class LiveReloadOrchestratorTest { assertThat(executor.requests).isEmpty() } + @Test + fun `a mid-rebuild edit the rebuild does not hold stays pending when its coarse mtime lands inside the granularity margin`() = + runTest { + // A filesystem with 1 s mtimes stamps a save made just after the rebuild started + // with an mtime a second before it, on a whole-second boundary. Absorbing on that + // mtime would lose the edit: onBaselineReset drops the absorbed set and nothing + // builds it. The precise 9_900 echoes in the tests above sit inside the same + // margin and must still absorb - the margin is for truncated mtimes only. + val executor = GatedExecutor() + val orchestrator = + LiveReloadOrchestrator( + executor, + ChangeClassifier(), + backgroundScope, + wallClock = { 10_000L }, + fileLastModified = { file -> if (file.path == srcA) 9_000L else 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 file with no readable mtime stays pending - nothing proves it predates the read`() = runTest { From 47f192cede582162befeeac2f2c776b404cfb178 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:26:06 -0700 Subject: [PATCH 08/11] ADFA-4128: ping the proxy app before calling a restart-deploy timeout an outdated runtime awaitDisconnect only watched the connection registry, which clears when linkToDeath fires. A loaded device can deliver that notification after the 5 s disconnect wait, and the deployer then reported a current runtime that had exited as one that "predates restart support" and forced a multi-minute Gradle rebuild. On timeout the channel now pings the registered binder. A ping is a transaction, so it fails as soon as the process is gone; the channel then clears the registry as the late notification would and reports the runtime gone, and the restart path proceeds to relaunch. A runtime that still answers keeps the outdated-runtime verdict. Review thread: https://github.com/appdevforall/CodeOnTheGo/pull/1718#discussion_r3914112874 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../service/deploy/DeployChannel.kt | 26 ++++++-- .../service/deploy/PayloadDeployer.kt | 6 +- .../service/deploy/DeployChannelWaitsTest.kt | 65 +++++++++++++++++++ quickbuild/docs/debugging.md | 2 +- 4 files changed, 89 insertions(+), 10 deletions(-) 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 index e7c3e88826..9532bd4bd4 100644 --- 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 @@ -58,7 +58,8 @@ interface DeploySender { * * @param timeoutMillis upper bound on the wait, sized for a runtime exit rather than a * process launch - * @return true when disconnected within [timeoutMillis] + * @return true when the runtime is gone within [timeoutMillis]: it disconnected, or its + * process no longer answers although the death notification has not landed yet */ suspend fun awaitDisconnect(timeoutMillis: Long): Boolean @@ -227,14 +228,27 @@ class DeployChannel( } } - override suspend fun awaitDisconnect(timeoutMillis: Long): Boolean = + 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 + val disconnected = + withTimeoutOrNull(timeoutMillis) { + connections.target.first { it == null } + true + } == true + if (disconnected) return true + // The registry clears on linkToDeath, which a loaded device can deliver well after + // the process died. A ping is a transaction, so it fails as soon as the process is + // gone; a runtime that is genuinely still running answers it. Without this, a late + // notification reads as a runtime that ignored the restart and costs a rebuild. + val connection = connections.target.value ?: return false + val binder = connection.target.asBinder() ?: return false + if (binder.pingBinder()) return false + log.info("Proxy app process is gone; its death notification has not landed yet") + connections.onDisconnected(binder) + return true + } override suspend fun awaitReconnect(timeoutMillis: Long): Long? = withTimeoutOrNull(timeoutMillis) { 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 7bd0720162..101079c8e5 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 @@ -188,9 +188,9 @@ internal class PayloadDeployer( 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. + // The runtime acked but its process still answers, 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 " + 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 index 13a710008a..3bbc052999 100644 --- 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 @@ -40,6 +40,42 @@ class DeployChannelWaitsTest { private fun connect(generation: Long) = connections.onConnected(ConnectedTarget(target, "com.example.quickbuild", generation)) + /** + * A target whose binder answers [IBinder.pingBinder] as scripted. A reflection proxy, + * because android.jar's [IBinder] is unmocked on the JVM and only the ping is live. + */ + private fun pingable(alive: Boolean): PingableTarget = PingableTarget(alive) + + private class PingableTarget( + alive: Boolean, + ) { + private val binder: IBinder = + java.lang.reflect.Proxy.newProxyInstance( + IBinder::class.java.classLoader, + arrayOf(IBinder::class.java), + ) { _, method, _ -> + when (method.name) { + "pingBinder" -> alive + else -> null + } + } as IBinder + + val target = + 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 + } + } + @Test fun `awaitDisconnect reports true when the proxy app actually disconnects`() = runTest { @@ -69,6 +105,35 @@ class DeployChannelWaitsTest { assertThat(channel.awaitDisconnect(5_000)).isTrue() } + @Test + fun `awaitDisconnect reports true on timeout when the process no longer answers a ping`() = + runTest { + // The death notification is late, not absent: the process is gone, so a ping + // fails. Reporting false here would attribute the timeout to a runtime that + // ignored the restart and cost a Gradle rebuild. + val dead = pingable(alive = false) + connections.onConnected(ConnectedTarget(dead.target, "com.example.quickbuild", 7)) + val awaited = async { channel.awaitDisconnect(5_000) } + + advanceTimeBy(5_001) + + assertThat(awaited.await()).isTrue() + assertThat(connections.target.value).isNull() + } + + @Test + fun `awaitDisconnect reports false on timeout when the process still answers a ping`() = + runTest { + val alive = pingable(alive = true) + connections.onConnected(ConnectedTarget(alive.target, "com.example.quickbuild", 7)) + val awaited = async { channel.awaitDisconnect(5_000) } + + advanceTimeBy(5_001) + + assertThat(awaited.await()).isFalse() + assertThat(connections.target.value).isNotNull() + } + @Test fun `awaitReconnect returns the generation the fresh process reported`() = runTest { diff --git a/quickbuild/docs/debugging.md b/quickbuild/docs/debugging.md index 5956128e8e..877eb92068 100644 --- a/quickbuild/docs/debugging.md +++ b/quickbuild/docs/debugging.md @@ -337,7 +337,7 @@ not, and changing those means editing the constant: `MODULE_SCAN_MAX_DEPTH`, `UI | Daemon request timeout | `DaemonProcessClient.DEFAULT_REQUEST_TIMEOUT_MILLIS` | 300 s | Per-request ceiling. Exceeding it fails that request and releases the slot; it does not by itself count as daemon death. | | Daemon shutdown grace | `DaemonProcessClient.SHUTDOWN_TIMEOUT_MILLIS` | 3 s | How long a polite `shutdown` is given before the child is killed. | | Deploy round trip | `DeployChannel.DEFAULT_TIMEOUT_MILLIS` | 15 s | One AIDL `onPayload` call. Exceeding it fails the deploy. | -| Restart-deploy disconnect wait | `LiveReloadExecutorImpl.DEFAULT_RESTART_DISCONNECT_TIMEOUT_MILLIS` | 5 s | How long the host waits for the proxy app to exit after a restart deploy. A runtime that acked but kept running is treated as an outdated baseline and forces a proxy app rebuild. | +| Restart-deploy disconnect wait | `LiveReloadExecutorImpl.DEFAULT_RESTART_DISCONNECT_TIMEOUT_MILLIS` | 5 s | How long the host waits for the proxy app to exit after a restart deploy. On timeout the host pings the binder, so a late death notification is not mistaken for a running app; a runtime that acked and still answers is treated as an outdated baseline and forces a proxy app rebuild. | | Restart-deploy reconnect wait | `LiveReloadExecutorImpl.DEFAULT_RESTART_RECONNECT_TIMEOUT_MILLIS` | 15 s | How long the host waits for the relaunched proxy app to rebind. | | Runtime rebind backoff floor | `QuickBuildClient.REBIND_MIN_DELAY_MS` | 1 s | First rebind delay inside the proxy app, doubled per failed attempt and reset on every successful connect. | | Runtime rebind backoff ceiling | `QuickBuildClient.REBIND_MAX_DELAY_MS` | 30 s | Ceiling for that doubling, so a CoGo that never comes back costs one attempt per 30 s. | From 0d3231f9bace655be41cc5782525b5136664819e Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 15:32:09 -0700 Subject: [PATCH 09/11] ADFA-4128: apply the mtime margin when the rebuild holds Unknown absorbEchoesLocked treated a held ChangedFiles.Unknown as "every file is already held" and skipped the granularity margin for all of them. After an external Gradle build untrusted the baseline (pending = Unknown) and a rebuild started, a .kt saved 0.5 s into the rebuild on a 1 s-mtime filesystem stamped a whole second before the start, was absorbed, and onBaselineReset dropped it - the edit was never built. An Unknown held set names no file, so nothing proves an arrival is an echo; only an enumerated held set keeps the exact cutoff. New test pins the Unknown case; a second new test pins the margin's upper bound (an 8_000 stamp against a 10_000 start absorbs), which with the existing 9_000 case fixes the constant at 2 s. Adversarial review 2026-09-04, finding #9 on 60d5a27ca. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../domain/reload/LiveReloadOrchestrator.kt | 5 +- .../reload/LiveReloadOrchestratorTest.kt | 56 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) 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 index 711b1d64d8..9219f61b59 100644 --- 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 @@ -507,6 +507,9 @@ class LiveReloadOrchestrator( * arrival is the echo of the save the rebuild is absorbing, and stranding that echo is * the spurious invalidation this split exists to prevent. The residual is a re-edit of a * held file within the margin after the start on a coarse filesystem, which absorbs. + * Only an enumerated held set earns that: a held [ChangedFiles.Unknown] (an untrusted + * baseline handed to the rebuild) names no file, so nothing proves an arrival is an echo, + * and every truncated mtime gets the margin - one spare build rather than a dropped edit. * * The absorbed part joins [awaitingAbsorption] via [ChangedFiles.plus], NOT * [unionPendingLocked]: this rebuild IS the Gradle build these files would demand, so @@ -522,7 +525,7 @@ class LiveReloadOrchestrator( val absorbed = changes.files.filterTo(mutableSetOf()) { file -> val mtime = fileLastModified(file) - val heldAlready = held !is ChangedFiles.Known || file in held.files + val heldAlready = held is ChangedFiles.Known && file in held.files val truncated = mtime % 1_000L == 0L val cutoff = if (heldAlready || !truncated) { 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 2f409efe48..c49ce92351 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 @@ -755,6 +755,62 @@ class LiveReloadOrchestratorTest { assertThat(executor.requests[0].changes).isEqualTo(known(srcA)) } + @Test + fun `a truncated mtime a full margin before the start is absorbed - the margin is two seconds, not more`() = + runTest { + // The test above pins the margin at more than 1 s (a 9_000 stamp stays pending). + // This one pins the other side: a whole-second stamp exactly 2 s before the start + // is inside the cutoff and absorbs, so the constant cannot silently grow past 2 s + // and strand a genuinely older save. Together they fix MTIME_GRANULARITY_MARGIN_MILLIS + // at 2_000 to within the whole-second grain the filesystems involved use. + val executor = GatedExecutor() + val orchestrator = + LiveReloadOrchestrator( + executor, + ChangeClassifier(), + backgroundScope, + wallClock = { 10_000L }, + fileLastModified = { file -> if (file.path == srcA) 8_000L else 0L }, + ) {} + + orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml")) + runCurrent() + orchestrator.onProxyAppRebuildStarted() + orchestrator.onFilesChanged(known(srcA)) + orchestrator.onBaselineReset() + runCurrent() + + assertThat(executor.requests).isEmpty() + } + + @Test + fun `a mid-rebuild edit gets the margin when the rebuild holds Unknown - nothing there proves it is an echo`() = + runTest { + // An external Gradle build untrusts the baseline (pending becomes Unknown), a rebuild + // then starts and takes that Unknown as its held set. A .kt saved 0.5 s into the + // rebuild on a 1 s-mtime filesystem reports a whole-second stamp before the start. + // Treating "held is Unknown" as "every file is held" skipped the margin, absorbed + // the save, and onBaselineReset dropped it - the edit was never built. + val executor = GatedExecutor() + val orchestrator = + LiveReloadOrchestrator( + executor, + ChangeClassifier(), + backgroundScope, + wallClock = { 10_000L }, + fileLastModified = { file -> if (file.path == srcA) 9_000L else 0L }, + ) {} + + orchestrator.onBaselineUntrusted() + 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 file with no readable mtime stays pending - nothing proves it predates the read`() = runTest { From af92f5b405fe60a06bce7fe3e3e2f9da45066a9a Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Sun, 6 Sep 2026 07:57:32 -0700 Subject: [PATCH 10/11] ADFA-4128: invalidate the retained payload when its directory cannot be deleted retain() and clear() discarded deleteRecursively's result, so a partial delete could leave a readable meta.json behind - contradicting this store's own "replaced wholesale by every retain" contract. Both now go through a private purge() that empties meta.json before the recursive delete and returns the delete's result. The order is the load-bearing part: unlinking a name needs write permission on the directory, rewriting a file's bytes needs it only on the file, so a truncated meta.json still fails load() closed in the case where the delete cannot proceed at all. load() keys off the metadata alone, so once that is unreadable the rest of the tree is inert. retain() checks the result into its existing catch; clear() warns when parts survive. Not propagated to callers: both are post-deploy bookkeeping on the success path, with the payload already live in the app. Failing a build for a retention housekeeping error would turn a cheaper-recovery optimisation into a build failure, and the documented fallback - the catch-up rebuild - still happens. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../service/deploy/RetainedPayloadStore.kt | 32 +++++++++++-- .../deploy/RetainedPayloadStoreTest.kt | 47 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) 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 e2326f069e..895497ee87 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 @@ -52,7 +52,9 @@ internal class RetainedPayloadStore( * 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. + * set, or nothing - never a half-written mix that [load] could hand to a re-send. A + * delete that fails is the same case: [purge] invalidates the old set before removing it, + * so this deploy failing to retain leaves nothing to load rather than the older set. * * @param generation the generation the confirmed deploy claimed * @param dexFile the deployed classes, or null when the build moved no code @@ -84,7 +86,7 @@ internal class RetainedPayloadStore( addProperty("hasAssets", assetsZip != null) }.toString(), ) - dir.deleteRecursively() + check(purge()) { "could not clear ${dir.absolutePath}" } check(staging.renameTo(dir)) { "could not move staging into ${dir.absolutePath}" } } catch (e: Exception) { staging.deleteRecursively() @@ -126,10 +128,34 @@ internal class RetainedPayloadStore( * generation supersedes the retained one but must never be replayed as a hot swap. */ fun clear() { - dir.deleteRecursively() + if (!purge()) { + log.warn("Could not fully drop the retained payload under {}; it is invalidated but its parts remain", dir) + } stagingDir().deleteRecursively() } + /** + * Removes the retained set, invalidating it before its parts go so a failed delete cannot + * leave a readable [META_NAME] behind. [File.deleteRecursively] reports failure by + * returning false and may already have deleted part of the tree, so its result is what + * decides whether the set is gone. + * + * The metadata is emptied rather than unlinked first: unlinking needs write permission on + * [dir] and writing needs it only on the file, so an emptied [META_NAME] still fails [load] + * closed in the case where the recursive delete cannot finish at all. [load] keys off the + * metadata alone, so once it is unreadable the rest of the tree is inert. + * + * @return true when the directory is gone; false when parts of it survive, in which case + * the set is still unreadable but the caller cannot rename a new one into place + */ + private fun purge(): Boolean { + val meta = File(dir, META_NAME) + if (meta.isFile) { + runCatching { meta.writeText("") } + } + return dir.deleteRecursively() + } + /** * One payload part of the retained set. * 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 index 9f2f879e6e..a71394b600 100644 --- 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 @@ -1,6 +1,8 @@ package org.appdevforall.cotg.quickbuild.service.deploy import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.io.File @@ -109,4 +111,49 @@ class RetainedPayloadStoreTest { assertThat(store.load()).isNull() } + + @Test + fun `a retain that cannot delete the old set leaves nothing loadable`() { + store.retain(1L, artifact("built.dex", "old-dex"), null, null, """{"gen":1}""") + assumeTrue(blockDeletion(), "the filesystem ignored the permission change") + + store.retain(2L, artifact("next.dex", "new-dex"), null, null, """{"gen":2}""") + + // Generation 1's bytes must not outlive a failed generation-2 swap: a re-send would + // replay them at a generation the session no longer deploys, leaving the app behind + // with nothing left to notice it. + assertThat(store.load()).isNull() + } + + @Test + fun `a clear that cannot delete the set still makes it unloadable`() { + store.retain(1L, artifact("built.dex", "old-dex"), null, null, """{"gen":1}""") + assumeTrue(blockDeletion(), "the filesystem ignored the permission change") + + store.clear() + + // clear() runs when the baseline changed or a restart deploy landed; either way the + // old bytes must never come back as a hot swap. + assertThat(store.load()).isNull() + } + + /** + * Makes the retention directory's entries impossible to unlink while leaving the entries + * themselves writable - the shape a failed `deleteRecursively` takes, since removing a name + * needs write permission on the directory and rewriting a file's bytes needs it only on the + * file. + * + * @return false when the platform ignored the permission change (a root test runner, or a + * filesystem without POSIX permissions), in which case the caller must skip + */ + private fun blockDeletion(): Boolean { + val dir = File(workDir, "last-deployed") + return dir.setWritable(false) && !dir.canWrite() + } + + @AfterEach + fun restoreRetentionDirPermissions() { + // @TempDir cleanup fails on a directory it cannot empty. + File(workDir, "last-deployed").setWritable(true) + } } From 9308be769aa84a99b8396f63205ad8743dac9279 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Sun, 6 Sep 2026 08:46:54 -0700 Subject: [PATCH 11/11] ADFA-4128: record the retained set's invalidation where a read-only dir cannot swallow it purge() emptied meta.json inside a runCatching and then returned the delete's result, so a store whose metadata write AND recursive delete both failed left meta.json intact and readable. load() parsed it and handed a superseded baseline's bytes to a reconnect re-send - the one thing this store exists not to do. Emptying the metadata and unlinking it need different permissions, which is why the class treated them as independent, but a read-only meta.json under a read-only directory refuses both at once. The invalidation is now recorded beside the directory when neither half took, and load() refuses a set carrying that marker. The marker needs only the parent, which retain() must be able to write anyway to rename its staging dir into place, and it is cleared as soon as a purge removes the set. Without the fix the new test reports: value of: load() expected: null but was : RetainedPayload(generation=1, metadataJson={"gen":1}, ...) Also corrects the purge() KDoc, which claimed a failed delete always leaves the set unreadable (board task t-jwpRC46uvGrH). It now states the one case that is still only logged - a store whose whole tree has gone read-only, where there is nowhere left to record anything - and clear()'s warning no longer asserts an invalidation it cannot guarantee. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../service/deploy/RetainedPayloadStore.kt | 40 +++++++++++++++---- .../deploy/RetainedPayloadStoreTest.kt | 28 +++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) 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 895497ee87..69b8bf324c 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 @@ -54,7 +54,8 @@ internal class RetainedPayloadStore( * 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. A * delete that fails is the same case: [purge] invalidates the old set before removing it, - * so this deploy failing to retain leaves nothing to load rather than the older set. + * so this deploy failing to retain leaves nothing to load rather than the older set - as + * far as [purge] can still record it, which its own contract bounds. * * @param generation the generation the confirmed deploy claimed * @param dexFile the deployed classes, or null when the build moved no code @@ -105,6 +106,10 @@ internal class RetainedPayloadStore( * (missing part, corrupt metadata) - either way the caller falls back to rebuilding */ fun load(): RetainedPayload? { + if (invalidMarker().isFile) { + log.warn("Retained payload under {} could not be invalidated in place; refusing it", dir) + return null + } val meta = File(dir, META_NAME) if (!meta.isFile) return null return try { @@ -129,7 +134,7 @@ internal class RetainedPayloadStore( */ fun clear() { if (!purge()) { - log.warn("Could not fully drop the retained payload under {}; it is invalidated but its parts remain", dir) + log.warn("Could not fully drop the retained payload under {}; its parts remain", dir) } stagingDir().deleteRecursively() } @@ -145,17 +150,38 @@ internal class RetainedPayloadStore( * closed in the case where the recursive delete cannot finish at all. [load] keys off the * metadata alone, so once it is unreadable the rest of the tree is inert. * - * @return true when the directory is gone; false when parts of it survive, in which case - * the set is still unreadable but the caller cannot rename a new one into place + * Both can fail together, though - a read-only [META_NAME] under a read-only [dir] refuses + * the write and the unlink alike - and then nothing inside [dir] has changed, so [load] + * would parse the superseded metadata and re-send a baseline the session has moved past. + * The marker beside [dir] is the third place to record it: it needs only the parent, which + * [retain] must be able to write anyway to rename its staging dir into place. + * + * @return true when the directory is gone; false when parts of it survive, in which case the + * caller cannot rename a new set into place. A false still leaves the old set unreadable - + * by the emptied [META_NAME], or by the marker when that write was refused too - except + * where neither could be written at all, which is a store whose whole tree has gone + * read-only and which [clear] can then only log */ private fun purge(): Boolean { val meta = File(dir, META_NAME) - if (meta.isFile) { - runCatching { meta.writeText("") } + val invalidated = !meta.isFile || runCatching { meta.writeText("") }.isSuccess + if (!invalidated) { + runCatching { invalidMarker().writeText(dir.name) } } - return dir.deleteRecursively() + if (!dir.deleteRecursively()) return false + // The set is gone, so the marker has nothing left to refuse; leaving it would refuse + // whatever the next retain puts here. + invalidMarker().delete() + return true } + /** + * The marker that makes [load] refuse a retained set [purge] could not invalidate in place. + * It sits beside [dir] rather than inside it, because the case it exists for is [dir] being + * unwritable. + */ + private fun invalidMarker(): File = File(dir.parentFile, dir.name + ".invalid") + /** * One payload part of the retained set. * 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 index a71394b600..a388eaf7aa 100644 --- 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 @@ -137,6 +137,20 @@ class RetainedPayloadStoreTest { assertThat(store.load()).isNull() } + @Test + fun `a set that can be neither emptied nor deleted is refused rather than re-sent`() { + store.retain(1L, artifact("built.dex", "old-dex"), null, null, """{"gen":1}""") + assumeTrue(blockMetadataWrite(), "the filesystem ignored the permission change") + assumeTrue(blockDeletion(), "the filesystem ignored the permission change") + + store.clear() + + // Both halves of the in-place invalidation are refused here, so nothing inside the + // retention directory changed at all. Reading the untouched metadata back would + // re-send generation 1 onto a baseline the session has already moved past. + assertThat(store.load()).isNull() + } + /** * Makes the retention directory's entries impossible to unlink while leaving the entries * themselves writable - the shape a failed `deleteRecursively` takes, since removing a name @@ -151,9 +165,23 @@ class RetainedPayloadStoreTest { return dir.setWritable(false) && !dir.canWrite() } + /** + * Makes the metadata impossible to rewrite - the other half of the invalidation, which fails + * independently of [blockDeletion] because it turns on the file's own mode rather than the + * directory's. + * + * @return false when the platform ignored the permission change, in which case the caller + * must skip + */ + private fun blockMetadataWrite(): Boolean { + val meta = File(File(workDir, "last-deployed"), "meta.json") + return meta.setWritable(false) && !meta.canWrite() + } + @AfterEach fun restoreRetentionDirPermissions() { // @TempDir cleanup fails on a directory it cannot empty. File(workDir, "last-deployed").setWritable(true) + File(File(workDir, "last-deployed"), "meta.json").setWritable(true) } }