From 81d351cc781beeb61946bbe3dfcad13c56f34d55 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 14:13:57 -0700 Subject: [PATCH 1/4] =?UTF-8?q?ADFA-4128:=20qb=2001/12=20docs=20=E2=80=94?= =?UTF-8?q?=20Design=20docs=20and=20ADRs=20=E2=80=94=20the=20map=20every?= =?UTF-8?q?=20later=20PR=20is=20read=20against?= 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 --- ...on-device-builds-via-gradle-tooling-api.md | 2 + ...015-quick-build-compiles-outside-gradle.md | 61 ++ docs/adr/README.md | 1 + quickbuild/README.md | 370 ++++++++ quickbuild/docs/component-proxying-design.md | 290 ++++++ quickbuild/docs/concurrency.md | 176 ++++ quickbuild/docs/debugging.md | 342 +++++++ quickbuild/docs/incremental-javac-design.md | 86 ++ quickbuild/docs/ksp-kapt-feasibility.md | 56 ++ quickbuild/docs/live-reload-alternatives.md | 253 +++++ quickbuild/docs/low-spec-devices.md | 132 +++ quickbuild/docs/manual-qa.md | 431 +++++++++ quickbuild/docs/perf-roadmap.md | 104 ++ quickbuild/docs/pipeline.md | 886 ++++++++++++++++++ quickbuild/docs/reliability-gaps.md | 155 +++ quickbuild/docs/resource-updates.md | 45 + quickbuild/docs/why-not-android-jar.md | 92 ++ 17 files changed, 3482 insertions(+) create mode 100644 docs/adr/0015-quick-build-compiles-outside-gradle.md create mode 100644 quickbuild/README.md create mode 100644 quickbuild/docs/component-proxying-design.md create mode 100644 quickbuild/docs/concurrency.md create mode 100644 quickbuild/docs/debugging.md create mode 100644 quickbuild/docs/incremental-javac-design.md create mode 100644 quickbuild/docs/ksp-kapt-feasibility.md create mode 100644 quickbuild/docs/live-reload-alternatives.md create mode 100644 quickbuild/docs/low-spec-devices.md create mode 100644 quickbuild/docs/manual-qa.md create mode 100644 quickbuild/docs/perf-roadmap.md create mode 100644 quickbuild/docs/pipeline.md create mode 100644 quickbuild/docs/reliability-gaps.md create mode 100644 quickbuild/docs/resource-updates.md create mode 100644 quickbuild/docs/why-not-android-jar.md diff --git a/docs/adr/0002-on-device-builds-via-gradle-tooling-api.md b/docs/adr/0002-on-device-builds-via-gradle-tooling-api.md index 21b0fb5790..775e3d04e4 100644 --- a/docs/adr/0002-on-device-builds-via-gradle-tooling-api.md +++ b/docs/adr/0002-on-device-builds-via-gradle-tooling-api.md @@ -20,6 +20,8 @@ Run builds with the **Gradle Tooling API in a separate JVM process**, and have t The app streams progress/events back from this process and renders them (e.g. `BuildState`, build output). The process runs on a **full out-of-process JDK** — the `java` binary from our terminal bootstrap packages (`appdevforall/terminal-packages`), launched by `ToolingServerRunner` — **not** the composite-build toolchains from [ADR 0003](0003-vendored-forked-desktop-toolchain.md), which are a separate, in-IDE-runtime concern. +**Scope:** this covers every build that produces an installable artifact, including Quick Build's own proxy-app provisioning. Quick Build's incremental per-save step is the one exception — it compiles outside Gradle, and the trade-offs are recorded in [ADR 0015](0015-quick-build-compiles-outside-gradle.md). + ## Consequences **Positive** diff --git a/docs/adr/0015-quick-build-compiles-outside-gradle.md b/docs/adr/0015-quick-build-compiles-outside-gradle.md new file mode 100644 index 0000000000..609476f66a --- /dev/null +++ b/docs/adr/0015-quick-build-compiles-outside-gradle.md @@ -0,0 +1,61 @@ +# 0015. Quick Build's live reload path compiles incrementally outside Gradle + +- **Status:** Proposed +- **Date:** 2026-08-12 +- **Deciders:** Code On The Go team + +## Context + +[ADR 0002](0002-on-device-builds-via-gradle-tooling-api.md) builds on device through real Gradle so results match a desktop build, and rejects a custom build engine. That still holds for anything a user installs or ships. + +Quick Build (ADFA-4128) does a different job: fast live reload, so a developer can iterate while writing code. A standard incremental Gradle build of a single app-module edit medians 4.7 s on a Galaxy A56 and 18.4 s on an A06, against 1.1 s and 2.8 s for Quick Build `[measured on a56, a06]`. + +Most of that time is not spent on the edit. A one-line Kotlin edit takes 7.8 s to build incrementally on an A06: + +- launch and configuration, 3.9 s - paid whatever the edit touched +- packaging and install, 1.1 s - to make an APK a running app does not need +- dex and resource link, 1.4 s - on outputs the edit did not change +- kotlinc, 1.2 s - the only stage the edit created + +The first three cannot be sped up or skipped. + +## Decision + +**Quick Build's live reload path does not use Gradle.** `:quickbuild:daemon`, a JVM child process of CoGo, compiles Kotlin with the Kotlin Build Tools API and Java with javac, then dexes with d8 and relinks resources with aapt2, using the SDK already on the device. No AGP, no r8. + +**Gradle handles what live reload cannot.** It still provisions the proxy app through the existing Tooling API path, and still builds every edit the classifier declines. Nothing a user installs or ships comes out of the daemon. + +**One compiler, not two.** Quick Build needs Kotlin 2.3.x for faster, more robust incremental compilation. Until the rest of CoGo moves up, the APK carries two Kotlin compilers. The move is in review as ADFA-2602; unifying them is ADFA-4931. + +## Consequences + +**Positive** + +- The edit loop is about 5x faster, and the gain is bigger on slower devices. +- The compiler stays warm between edits - the biggest single latency lever, and something Gradle cannot do. +- A compiler crash kills the daemon, not the IDE, and the daemon can be shut down to give Gradle its memory back. + +**Negative - inherent to the decision** + +- Output is not identical to Gradle's. That is deliberate: close enough on the cases that matter beats full compatibility. +- A second build pipeline to maintain. It will drift from AGP, and we cannot use Gradle as ground truth, so it needs its own ongoing testing - which is slow, because builds on low-spec devices are slow. + +**Negative - solvable with more work** + +- No annotation processing. kapt and KSP edits go to Gradle; KSP looks tractable, see [ksp-kapt-feasibility.md](../../quickbuild/docs/ksp-kapt-feasibility.md). +- Live reload covers a narrow set of edits today; the rest fall back to Gradle. Conservative defaults, not hard limits. +- Memory is not tuned. Gradle and Quick Build share it, and idle timeouts are all that keeps them out of each other's way. + +## Alternatives considered + +- **Gradle with fewer tasks** — rejected: the cost is mostly configuration and task-graph work, which fewer tasks do not remove, and it still builds an APK rather than a deployable payload. +- **Compile in-process inside the IDE** — rejected for ADR 0002's own reason: a compiler OOM would take the editor with it. +- **Replace the proxy-app build too** — rejected: it would drift from AGP on the one artifact where that is unacceptable. +- **ART hot-swap (Apply Changes)** — rejected: needs an attached debugger and replaces only method bodies. +- **Patch the android.jar** — rejected as infeasible; see [why not android.jar](../../quickbuild/docs/why-not-android-jar.md). + +## Related + +- [ADR 0002](0002-on-device-builds-via-gradle-tooling-api.md) — still governs full builds and Quick Build's provisioning. +- [ADR 0004](0004-embedded-termux-runtime.md) — the daemon runs on the bundled JDK. +- [`quickbuild/README.md`](../../quickbuild/README.md) — design and measured numbers. diff --git a/docs/adr/README.md b/docs/adr/README.md index 5b7b7fa226..1f5c0ecbe7 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -28,3 +28,4 @@ Format is lightweight **MADR / Nygard**: Context → Decision → Consequences | [0012](0012-volatile-build-metadata-out-of-abis.md) | Keep volatile build metadata out of module ABIs | Proposed | | [0013](0012-refactoring-ui-lives-in-the-owning-lsp-module.md) | Refactoring UI lives in the owning LSP module | Proposed | | [0014](0013-refactorings-decline-rather-than-rewrite.md) | Interactive refactorings decline rather than rewrite unselected code | Proposed | +| [0015](0015-quick-build-compiles-outside-gradle.md) | Quick Build's per-save path compiles incrementally outside Gradle | Proposed | diff --git a/quickbuild/README.md b/quickbuild/README.md new file mode 100644 index 0000000000..e17dc23525 --- /dev/null +++ b/quickbuild/README.md @@ -0,0 +1,370 @@ +# Quick Build (ADFA-4128) + +Quick Build makes the on-device edit loop much faster. Tap the lightning-bolt button once and **CoGo** (Code On The Go, this IDE) installs a generated **proxy app** - a live-reloading build of the user's project. From then on every compatible save reaches the running app in seconds, with no Gradle build and no reinstall. The whole loop runs on device - edit, watch, compile, dex, deploy, reload. + +From the ADFA-4128 benchmark pass of 2026-08-11, on CoGo dev build `C-d-0810-2347` - realistic edits drawn from a corpus of open-source apps, comparing Quick Build's save-to-live-reload against a standard *incremental* Gradle build of the same edit: + +| Device | Warm edits | Median save to live | Median incremental Gradle build | Speedup | p25-p75 | +| ------------------------------------------------------------ | -------------- | ------------------- | ------------------------------- | --------- | ------------- | +| **Galaxy A06** - 3.5 GB, entry-level (eight Cortex-A55 cores, no big core) | 79 over 24 apps | 2822 ms | 18401 ms | **6.53x** | 4.53x - 9.10x | +| **Galaxy A56** - 8 GB, current mid-range; our reference device | 76 over 23 apps | 1094 ms | 4662 ms | **4.35x** | 3.28x - 5.84x | + +Both devices together: **5.12x** over 155 edits, p25-p75 3.92x - 7.75x `[measured on a56, a06]`. Speedup is the median of per-edit paired ratios - each edit's standard build divided by its own Quick Build, same edit, same app, same device - which is the correct paired statistic and differs from dividing the two median columns. The speedup is largest on the slowest device. + +Three things that number does not say: + +- **It is conditional on a save that live-reloaded.** Quick Build produced a reload on 155 of 192 attempted edits, 80.7% `[measured on a56, a06]`: 21 misses were its own compile or deploy failing, 12 were provisioning, and 4 were the classifier declining by design. +- **The Gradle side excludes the install and launch it needs**, which biases the comparison against Quick Build. +- **It is not always faster.** 2 of 155 edits lost, both a Java ABI change in `sora-editor-full`, at 0.65x on the A06 and 0.76x on the A56. And the first project open is slower, once per session: 70.3 s against 50.3 s for a standard Run on the A56 (1.40x slower), 262.2 s against 165.4 s on the A06 (1.59x slower) `[measured on a56, a06]`. + +## Goals + +1. **Live-reload should be fast enough that the user stays in flow.** Under 1s is ideal, but we're not there yet on most devices. +2. **The proxy app behaves like the real app, and is never stale.** Same `applicationId`, permissions, components and resources; and every edit either live-reloads or visibly falls back to a real Gradle build. +3. **Avoid modifying the user's code.** We use a Gradle plugin to create the proxy app that works as a wrapper, and try not to modify any of the user's app otherwise. +4. **Good enough, but no need to be 100% compatible.** Where the proxy app cannot match the real app, make that clear to the user - see [the boundary](#edit-types-that-can-live-reload) and [Known limitations](#known-limitations-v1). We're not trying to match a Gradle build exactly, just to be useful. +5. **Accept some tradeoffs to make live reload fast, but try to reduce tradeoffs** + 1. A reasonable amount of extra time at project open is OK - today the first open costs ~20 s more than a standard Run's first build on the A56 `[measured on a56]`. + 2. We need some memory to keep Quick Build's compile daemon resident and available. +6. **Runs offline, on device.** Same standard as Code on the Go. + +## Overview + +### Terms + +| Term | Meaning | +| --------------------- | ------------------------------------------------------------ | +| **Standard Run** | CoGo's ordinary Run button: a full Gradle build that installs and launches the real app. Quick Build's fallback, and the thing it shares a Gradle slot and the device's single install slot with. | +| **Proxy app** | The installable app Quick Build generates and runs in place of a Standard Run install: the `:quickbuild:runtime` AAR plus the user's libraries and resources under the project's real `applicationId`, with generated **proxy components** (`Proxy0Activity`, ...) standing in for the user's. "Proxy" alone always means those components, never the app. | +| **Baseline** | The last full proxy app build's output, which every live reload is computed against: the baseline dex baked into the installed proxy app (`gen-0.dex`, booted at the generation stamped beside it), its fingerprint, and the orchestrator's matching state. | +| **Live reload** | The quick path after `ChangeClassifier`: compile in the daemon, deploy a payload, the running proxy app updates. One cycle is one reload. | +| **Payload** | The compiled user code (plus, for a resource edit, the relinked resource apk) sent to the running proxy app for one reload, without a reinstall. | +| **Generation** | A monotonic counter naming each payload; the proxy app runs one generation. | +| **Proxy app rebuild** | Falling back to a fresh proxy app build when live-reload state cannot be trusted. Refreshes the baseline and tears the daemon down for its duration (freeing its RAM for the Gradle peak). Stale persisted payloads are not cleared by session control - the runtime discards them itself when its stored baseline fingerprint stops matching. | +| **Warm compile** | A background build (`BuildRoute.WarmCompile`, never produced by the classifier) that warms the daemon's incremental caches and deploys nothing. Lowest priority. | +| **Scratch tree** | CoGo's private per-session working directory (`no_backup/quickbuild-scratch/...`) holding the daemon's work and out trees. Deleted on teardown. | + +### Edit Types That Can Live Reload + +Quick Build handles only some types of edits using live reload. For edits it can't handle yet, it falls back on a longer Gradle build. + +| Live reload (fast path) | Proxy app rebuild (slow path, via Gradle) | +| ------------------------------------------------------------ | ------------------------------------------------------------ | +| App-module source edits (Kotlin or Java)
Resource value edits
Asset changes | Source edits in a non-app module
Manifest changes
Native `.so` changes
Annotation-processor input edits
Gradle file changes | + +Over time we can try to expand what can live reload, but some of these edit types will be harder to support. + +The authoritative list is the classifier's `BuildRoute` / `InvalidationReason` enumeration ([`BuildRoute.kt`](core/src/main/java/org/appdevforall/cotg/quickbuild/domain/classify/BuildRoute.kt)). + +### Quick Build Workflow Overview + +Three things can trigger the Quick Build workflow: + +1. **Project opened** + 1. A **proxy app prebuild** runs in the background: build only, no install, no daemon. +2. **File(s) written** + 1. An editor save, `git pull`, termux script or plugin write triggers a live reload. +3. **Quick Build button tapped** + 1. Flushes the editor's dirty buffers to disk (which can trigger a live reload) + 2. Provisions the session on the first tap + 3. Switches to the proxy app + +At a high level, `:quickbuild:core` (inside CoGo) does the thinking, and it routes each change down one of two paths: + +```mermaid +flowchart LR + trig(["File saved, or
Quick Build button tapped"]) --> core["Quick Build Core
(quickbuild:core)

Detect and classify the change, manage the build session, choose the route. Runs in Code on the Go."] + project_open(["Project opened"]) -- "trigger initial baseline
Proxy build" --> gradle + gradle -- "app install + restart" --> proxy +core -- "live reload
(uses quickbuild:protocol)" --> daemon["Compile Daemon
(quickbuild:daemon)

compile + dex just the change"] + daemon -- "securely transfer payload (using AIDL)" --> proxy["Proxy App
(quickbuild:runtime)

Running proxy app reloads changes in place and restarts Activity"] + core -- "full Gradle build" --> gradle["Proxy App Rebuild
Gradle build and reinstall using plugin"] + +``` + +For more depth on each component (component diagrams and more sequence diagrams), see [`docs/pipeline.md`](docs/pipeline.md#the-four-processes-and-every-hop-between-them). + +### Map of the Code + +Here's a more detailed map of the key components: + +`:quickbuild:core`'s `domain/` layer is the pure-JVM, Android-free floor - all the routing and session logic, unit-testable without a device. Every Android capability it needs is a port it declares and `:app` implements, wired in one Koin module ([`di/QuickBuildModule.kt`](../app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt)); the module's own `data/` and `service/` layers touch `android.*` only where a port's implementation is inherently framework-bound. Detail: [`core/README.md`](core/README.md). + +| Module | Responsibility | Entry point | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | +| [`:quickbuild:core`](core/README.md) | The orchestration layer - it watches for file changes, classifies changes, and then orchestrates live reload via the daemon or (re)building the proxy app using Gradle. The core makes sure that all changes eventually lead to a consistent proxy app (or a clear error shown to the user) | [`LiveReloadOrchestrator`](core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestrator.kt), [`ChangeClassifier`](core/src/main/java/org/appdevforall/cotg/quickbuild/domain/classify/ChangeClassifier.kt), [`SessionReducer`](core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/SessionReducer.kt), [`QuickBuildSessionManager`](core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManager.kt) | +| [`:gradle-plugin`](../gradle-plugin/src/main/java/com/itsaky/androidide/gradle/QuickBuildPlugin.kt) | Gradle plugin that minimally wraps the user's app to create the proxy app | [`QuickBuildPlugin`](../gradle-plugin/src/main/java/com/itsaky/androidide/gradle/QuickBuildPlugin.kt), [`ProxySourceGenerator`](../gradle-plugin/src/main/java/com/itsaky/androidide/gradle/quickbuild/ProxySourceGenerator.kt) | +| [`:quickbuild:runtime`](runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/) | Java-only AAR that runs inside the proxy app and securely connects back to Code on the Go and handles live reloads and connection lifecycle. The runtime defines an AIDL interface for bidirectional communication with `quickbuild:core`. | [`QuickBuildAppComponentFactory`](runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory.java), [`PayloadStore`](runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java), [`ResourceSwapStrategy`](runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceSwapStrategy.java) | +| [`:quickbuild:daemon`](daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/) | JVM child process of Code on the Go that handles incremental Kotlin compile via the Kotlin Build Tools API, javac, d8 (DEXing), aapt2 (updating resources) | [`DaemonMain`](daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/DaemonMain.kt), [`DaemonService`](daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/DaemonService.kt) | +| [`:quickbuild:protocol`](protocol/README.md) | Interface definition between core and compile daemon | [`DaemonProtocol.kt`](protocol/src/main/kotlin/org/appdevforall/cotg/quickbuild/protocol/DaemonProtocol.kt) | +| `:app` layer | Integration points in the Code on the Go IDE, including the toolbar button, the Koin graph binding every port to Android, and the Firebase + bench metrics sinks | [`QuickBuildAction`](../app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt), [`QuickBuildModule`](../app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt), [`QuickBuildMetricsSink`](core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/QuickBuildMetricsSink.kt) (port) | + +## Live Reload Protocol Between Code on the Go and Proxy App + +How `:quickbuild:core` gets a compiled change into the running app - the step the overview glosses over. Wire formats and version-skew rules are in [`protocol/README.md`](protocol/README.md). + +### Proxy-App Architecture + +What gets proxied: every **manifest-declared** activity, service, receiver, and provider gets a generated `Proxy extends ` compiled into the APK; the `Application` keeps the user's class (the runtime already hooks process start), and runtime-registered receivers are ordinary objects needing nothing. + +What the installed proxy app is made of: + +```mermaid +flowchart LR + subgraph apk["Installed proxy app APK - under the user's real applicationId"] + rt["The runtime AAR"] + libs["The user's libraries and resources"] + man["A manifest naming proxy components
(Proxy0Activity, Proxy1Service, ...)"] + gen0["gen-0.dex - a baseline copy
of the user's classes"] + end + + payload[["Payload dex, arriving per reload:
the user's classes, plus their proxies"]] --> apk +``` + +The APK's own dex holds **no user classes at all**. They live only in the payload, which is why a reload can replace every one of them and why parent-first delegation can never serve a stale copy. + +- **A proxy is a subclass, not a delegate.** `Proxy0Activity extends com.user.MainActivity`, so the manifest name stays fixed while the class beneath it is replaced wholesale. Proxy and user class travel in the same payload dex, so a reload swaps them together. +- **Activity proxies exist for one runtime reason:** they override `getClassLoader()`. `Context#getClassLoader()` is otherwise pinned to the APK loader, so by-name resolution (`LayoutInflater` custom views, `FragmentFactory`, Navigation destinations) would never find a payload-only class. + +How the proxies and the baseline dex are generated, which components get one, and the ones deliberately never proxied: [`docs/component-proxying-design.md`](docs/component-proxying-design.md). + +### The Deploy Channel + +Two AIDL interfaces in [`runtime/src/main/aidl/`](runtime/src/main/aidl/com/itsaky/androidide/quickbuild/) - [`IQuickBuildTarget`](runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildTarget.aidl) (proxy app side, `oneway`) and [`IQuickBuildHost`](runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildHost.aidl) (CoGo side). The handshake, then one successful payload: + +```mermaid +sequenceDiagram + participant App as Proxy app: QuickBuildClient
(binds on launch, applies payloads) + participant Host as CoGo: QuickBuildHostService
(the exported binder service the app calls) + participant Dep as CoGo: PayloadDeployer + DeployChannel
(drives each deploy, pushes to the app) + + Note over App,Dep: Handshake - once per app launch + App->>Host: bindService(QUICK_BUILD_ACTION, BIND_AUTO_CREATE) + App->>Host: connect(target, packageName, runningGeneration) + Host->>Host: enforceCaller: getCallingUid() == the installed proxy app's uid + Host->>Dep: ConnectedTarget published on ProxyAppConnections + + Note over App,Dep: One deploy - once per live reload + Dep->>App: onPayload(gen N, dexFd, resourcesFd, assetsFd, metadataJson) + App->>App: accept only if N is strictly newer, persist, swap, recreate + App->>Host: reportReloaded(N, reloadMillis) + Host->>Dep: DeployResult.Reloaded +``` + +1. **The proxy app calls CoGo first.** `QuickBuildHostService` is `exported` - the proxy app is a different package - so nothing can be delivered until the app has bound and registered its callback. A reinstall therefore has to re-establish the connection before the next payload; until it does, a deploy returns `NotConnected` and CoGo launches the app once and retries. +2. **The uid check is the whole trust boundary for calls into CoGo, and it runs on every inbound call.** `enforceCaller` throws unless `Binder.getCallingUid()` matches the uid the live session accepts, taken from `PackageManager` at install time and never from anything the caller sent. No live session means nothing is accepted `[inferred]`. It is not what protects the app: see the next point. +3. **What stops the running app taking code from anywhere is that it never publishes a receiving endpoint.** The app binds out to CoGo by explicit package name and hands back a `Binder` callback over that binding, so there is no port, no exported component and no file path on the app's side - delivering a payload means holding that callback, and the only process ever given it is CoGo's. Binder handles come from the kernel, so another app cannot guess or forge one. **Known gap:** the app trusts CoGo by *package name*, not by signing key. Android will not let a second app claim a name already installed, so this is narrow - but a proxy app left on a phone after CoGo is uninstalled would bind to whatever later claims that name. A signing-cert check at bind time closes it. +4. **Payloads travel as file descriptors, not paths or bytes.** `DeployChannel` opens the dex, the relinked resource apk and the assets zip `MODE_READ_ONLY` and passes the `ParcelFileDescriptor`s across binder; the files themselves stay in CoGo's private scratch tree. No socket, no port, no shared-storage drop - so nothing to firewall and nothing another app can read `[inferred]`. +5. **Generations decide what applies.** The runtime accepts a payload only when its generation is *strictly* newer than the one it runs, loads it through an `InMemoryDexClassLoader`, and persists it app-privately so a relaunched process boots the newest persisted generation rather than the baseline (the baseline itself boots at the generation the proxy app build stamped into the APK, so a rebaselined app reconnects in-sync rather than at 0). A reload that throws rolls back to the previous generation and calls `reportCrash`, so the app keeps running the last working code and says so. +6. **Every wait is bounded.** `onPayload` is `oneway`, so the send returns immediately; `DeployChannel` subscribes to the reports flow *before* the call and matches replies by generation, so a superseded build's report is never mistaken for the current one. A hung app becomes `DeployResult.TimedOut` (15 s) and `linkToDeath` makes a dead one fail fast, rather than either stalling a build. +7. **Services, providers and a custom `Application` swap by process restart**, never hot-swap of a live instance ([`DeployPolicy`](core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/DeployPolicy.kt)). + +The two `.aidl` files *are* the contract - `:quickbuild:core` compiles the same files via `aidl.srcDir("../runtime/src/main/aidl")` rather than depending on the runtime module, so the two ends cannot drift within one source tree. Append methods only, never reorder or remove; the runtime AAR is compiled *into* the proxy app, so a new message only exists after a rebuild and reinstall. + +## Session Management and Concurrency + +A lot arrives at once: saves landing while a build runs, a button tap mid-build, an external Standard Run build, a daemon that dies, a proxy app that crashes. + +The orchestrator in `quickbuild:core` tries to maintain two invariants across all of it: + +1. **Nothing gets missed.** Once outstanding changes and interactions have been processed successfully, the running proxy app reflects the codebase. A save leaves the pending set only via a build that succeeded with it. +2. **Errors are visible and recoverable.** Any state we cannot trust is named to the user and has a path back to a working session - ultimately a proxy app rebuild. + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Prebuilding: project opened + Idle --> Provisioning: button tapped + Prebuilding --> Provisioning: a tap queued during the prebuild + Prebuilding --> Idle: prebuild finished, no tap + Provisioning --> Ready: proxy app installed, daemon up + Provisioning --> Idle: provisioning failed + Ready --> Building: a coalesced batch of changes, or the warm compile + Building --> Deployed: deployed at generation N+1 + Building --> Ready: compile error + Deployed --> Building: the next batch + Ready --> Invalidated: a change the live path cannot absorb + Building --> Invalidated: a change the live path cannot absorb + Deployed --> Invalidated: a change the live path cannot absorb + Invalidated --> Provisioning: proxy app rebuild + Ready --> Degraded: daemon died + Building --> Degraded: daemon died + Deployed --> Degraded: daemon died + Degraded --> Ready: daemon respawned, then a warm compile + note right of Building + Compile error is not a state change: back to Ready + at the SAME generation, lastFailure set (never stale). + A ProxyAppCrashed from Ready or Deployed lands the + same way; mid-build the imminent deploy supersedes + the crashed code, so it is dropped (or carried until + the warm compile finishes). + end note +``` + +Every edge is in [`docs/pipeline.md` step 2](docs/pipeline.md#step-2-session-control-and-provisioning-quickbuildcore-service--app); the nine `InvalidationReason` values and the retry budgets are in its step 7; the full diagram with every guard sits next to the reducer in [`domain/session/README.md`](core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/README.md). [`SessionReducer`](core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/SessionReducer.kt) is the authority. The reducer is *total* - an unhandled `(state, event)` pair is a no-op - which is why every guard below can be "drop it" rather than "unwind it". + +How the triggers get sequenced: + +- **One thread decides everything; every expensive thing runs in another process** `[inferred from code]`. The reducer, all session effects and all session state live on one `QuickBuildSession` thread with no locks. Compiling happens in the daemon, Gradle in CoGo's tooling server, reloading in the proxy app; every result hops back onto the session thread before it touches state. +- **A burst of saves becomes one batch** - coalescing emits 150 ms after the last write, capped 1 s from the first, last event per path winning. +- **One build in flight.** Starting a build *moves* the pending set into it; the set clears only on success and a failed batch is unioned back, so saves arriving mid-build simply join the next one. New work never cancels a running compile - it waits. +- **Stale work cannot apply itself.** Every result carries its build id, and two epochs (session and daemon) guard every async result, so a build superseded by a teardown or a baseline reset is discarded rather than rendered. +- **A deploy racing a reconnect is safe**, because the proxy app takes a payload only if it is strictly newer than what it runs. +- **The warm compile is what makes the first save fast** - worth 6.1x on it `[measured on a56]` ([`docs/perf-roadmap.md`](docs/perf-roadmap.md)). It starts only after `Ready` is reached, so it costs nothing on the way there. +- **Standard Run contention is gated, not locked.** The one Gradle slot answers `SlotBusy` as a distinct outcome rather than a build failure, and the device's single install slot (one install per `applicationId`, shared by the proxy app and a Standard Run install) is confirmed statelessly before either side clobbers the other. It goes both ways: any completed Standard Run build hands state back to a live session, refreshing or invalidating its baseline. + +Which threads exist, what each gate does, the mid-build sequence in full, and the reliability-mechanism table: [`docs/concurrency.md`](docs/concurrency.md). + +## Notable Decisions + +### Build Triggers On File Write + +Watching the filesystem handles every kind of write - editor save, autosave, `git pull`, a termux script, a plugin - through one path, and starts compiling as soon as bytes land instead of accumulating changes until the button is pressed. Alternatives: instrumenting editor events, which means catching several events reliably and still misses every write from outside the editor; building on tap only, which is simpler and saves some battery but is slower at the moment that matters. + +### Use AIDL for Secure Communication Between Code on the Go and Proxy App + +AIDL plus `ParcelFileDescriptor`s and a per-call uid check - no sockets, no ports, no world-readable files, so no other app on the device can read a payload or impersonate CoGo. Cost: the proxy app must bind back to CoGo before anything can be delivered, so every rebuild re-establishes that connection. + +### Create Proxy App Using Gradle Plugin + +The plugin runs inside the project's own AGP build, because only that build computes the merged manifest, resource ids and dependency classpath correctly. CoGo injects it at provisioning time through its Gradle init script - the user's own build files are never edited. Cost: session start pays one real Gradle build. Alternatives: post-processing the built APK (binary-XML surgery, re-signing, and nowhere to generate proxy sources); a minimal build reimplemented in CoGo (drifts from AGP semantics); replacing `android.jar` (judged too complex and infeasible in early discussions - [`docs/why-not-android-jar.md`](docs/why-not-android-jar.md)). + +### Proxy App Uses Same Application ID + +`${applicationId}` authorities pass verbatim and package-bound integrations (Firebase, FCM, app links) reach the proxy app. Cost: Quick Build and Standard Run share the device's one install slot, so the UI confirms before clobbering - read statelessly from the installed package's `android:appComponentFactory` ([`RealIdInstall.kt`](core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/RealIdInstall.kt)) - and a foreign-signature occupant is refused outright. Alternative: a `.quickbuild`-suffixed id, which would let both coexist but breaks placeholder authorities and every package-bound integration; that two-mode design was removed on 2026-07-24. + +### Compilation Lives In Separate Process + +A stateless warm daemon, with all routing policy left in CoGo. It isolates the compiler's crash domain and memory (537 MB RSS over a 28-minute soak on a mid-spec phone, `phase1-gates-a56` - the main low-spec risk) and keeps the compiler warm, which is the biggest latency lever. Alternative: compiling in-process - no spawn cost, but a compiler OOM takes the IDE with it and its heap sits in CoGo's budget forever. + +### Raise Gradle's Metaspace Cap and Let an Idle Daemon Hand Heap Back + +Two daemons now share one phone's memory, so the Gradle side had to be retuned. All three build strategies ([`BalancedStrategy.kt`](../app/src/main/java/com/itsaky/androidide/services/builder/BalancedStrategy.kt), [`LowMemoryStrategy.kt`](../app/src/main/java/com/itsaky/androidide/services/builder/LowMemoryStrategy.kt), [`HighPerformanceStrategy.kt`](../app/src/main/java/com/itsaky/androidide/services/builder/HighPerformanceStrategy.kt)) raise the Metaspace cap from 192 to 384 MB, because 192 MB OOM'd real builds on a 3.6 GB C107, and each gains a per-tier daemon idle timeout so an idle Gradle daemon returns heap to the quick-build daemon instead of holding it. Cost: a build that starts after the timeout pays daemon start again, and peak footprint rises on devices that were already tight. Alternative: a single shared daemon budget, which removes the handback problem but makes either daemon able to starve the other. + +### Session State Lives in a Service-Held Manager, Not a ViewModel + +A Quick Build session outlives the editor Activity - it survives rotation, backgrounding, and the editor being torn down and rebuilt, because the compile daemon and the app's binder connection stay up across all three. A `ViewModel` is scoped to a `ViewModelStoreOwner`, the wrong lifetime for that, so the session lives in a service-held manager and the toolbar renders from the state it exposes. + +### Reload Using Public APIs Only (Classloader Swap) + +A reload swaps the payload classloader plus the resource apk and restarts components; it never patches code in place. Cost: restart granularity, which never-stale prefers anyway. Alternatives: reinstalling per edit (install latency and a confirm dialog per save); ART hot-swap as in Apply Changes (needs an attached debugger, method bodies only); Tinker-style dex patching (reflection into ART internals). + +### Build Scratch Lives in Faster Private Storage + +The daemon's work and out trees live in CoGo's `noBackupFilesDir`, not the project tree: the project sits on FUSE-backed shared storage, and moving off it cut warm edits by ~36% subset-median `[measured on a56]`. Cost: not user-browsable, so the tree carries a 100 MB guard, teardown deletion and a stale sweep. The generation counter deliberately stays in the project tree so it survives scratch cleanup. + +### Benchmarking Corpus Lives in Separate Repo + +Synthetic apps ship with their oracles and results in the `CodeOnTheGo-build-benchmark` repo; real apps are pinned by `vendor.json` and fetched into a gitignored cache, so third-party source is never checked into any repo. The harness drives CoGo only through the declared interfaces, so it cannot mask a break in them. + +### The Per-Save Path Does Not Use Gradle + +Provisioning runs a real Gradle build, but every save after it does not - the daemon compiles, dexes and swaps resources directly, because a Gradle invocation per save costs seconds that this feature exists to remove. ADR 0002 chose the Gradle Tooling API for on-device builds and still reads as covering all of them, so this branch adds ADR 0012 to record the second path and its limits rather than leave 0002 quietly overstated. Cost: two build paths to keep honest. The proxy app is only ever produced by AGP, and the per-save path is never allowed to produce an installable artifact. + +### The Proxy App Connection Registry Is a Process-Wide Singleton + +[`ProxyAppConnections.INSTANCE`](core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppConnections.kt) is process-wide and bound into Koin, which ADR 0006 discourages. Android instantiates `QuickBuildHostService` itself, so the registry cannot be constructor-injected into it. The existing Gradle build hits the same constraint and answers it the same way - `GradleBuildService` publishes itself into the process-wide `Lookup` registry when it starts. This differs only in going through Koin rather than the legacy locator, so the dependency stays visible and swappable in tests. Cost: one piece of global state whose lifetime is the process rather than a scope. + +## Working on Quick Build + +### How to Test + +Unit and Kaspresso tests cover CoGo itself; anything that crosses into the proxy app needs one of the two on-device tiers below. + +- **Run the unit tests.** They live in each module's `src/test` - `:quickbuild:core` carries most of them (the domain layer is pure JVM by design), with more in `:quickbuild:daemon` and `:gradle-plugin`. Run them with `flox activate -d flox/local -- ./gradlew :quickbuild:core:test :quickbuild:daemon:test :gradle-plugin:test`. + +- **Script a session over `adb`.** Under the `CodeOnTheGo.qbbench` flag an exported activity opens a project and fires the first tap in place of a human, so a whole session - including a retry after an install-confirm timeout - runs unattended. Command and options: [`docs/debugging.md` §6](docs/debugging.md). +- **Run the corpus.** The `CodeOnTheGo-build-benchmark` repo carries the open-source app corpus, realistic edits and the E2E harness. Correctness comes from its two oracles - recompiled-class bounds and output equivalence - not from timings. Commit the results dir for any compile-pipeline change, and cite one for any latency claim. + +A new edit class or route needs all three: a classifier test, a corpus edit declaring `expected.route`, and an on-device walk if it deploys. Two traps: the root build sets `ignoreFailures = true` on test tasks, so read `/build/test-results/` rather than trusting `BUILD SUCCESSFUL`; and nothing runs the real daemon jar against the real client ([`DaemonProcessClientTest`](core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientTest.kt) drives a scripted fake), so a protocol regression that compiles only surfaces on device. + +### How to Run On Device + +Build and install `:app:assembleV8Debug` from this branch - Quick Build has not shipped in any release, and `:app` needs the gitignored, team-provided `app/google-services.json`. Then drop flag files in the device's `Download/` folder and **restart CoGo**, because flags are read once per process ([`FeatureFlags.kt`](../common/src/main/java/com/itsaky/androidide/utils/FeatureFlags.kt)). + +| Flag file | Effect | +| ---------------------- | ------------------------------------------------------------ | +| `CodeOnTheGo.exp` | the experiments flag; required - without it the lightning-bolt button does not appear | +| `CodeOnTheGo.qbbench` | adds the adb entry point and the `bench-events.jsonl` event log; **debug builds only** - the benchmark code lives in `app/src/debug/` and is absent from a release APK | +| `CodeOnTheGo.qbnoseed` | suppresses the post-provisioning warm compile so an A/B runs against the same installed build; inert without `.qbbench`, never on in a shipping build | + +### When a Save Doesn't Show Up + +Work down this list and stop at the first answer. + +- **Was the file watched?** The most common cause, and silent by design. Only `/src` trees and a named set of Gradle files are watched, so a file one directory outside them produces no event at all. +- **Did a build start?** `adb logcat | grep QB-` catches the whole feature, and each tag stays individually greppable (`adb logcat -s QB-SessionManager`). Every state transition logs there. +- **Where did the time go?** The end-to-end timeline is one line under `QB-ReloadExecutor`. +- **Which process should I be looking at?** Three log differently: CoGo under the `QB-` tags; the proxy app under the single tag `QB-Runtime`; and the daemon not at all - it writes stderr, which `DaemonProcessClient` re-logs as `daemon(stderr): ...`, so if CoGo dies that output is gone. + +Full triage in that order, the exact watch rules, on-device paths, the log-tag conventions and every timeout: [`docs/debugging.md`](docs/debugging.md). + +### Areas to Be Careful Of + +Each of these breaks the feature without any test going red, so a change touching one needs a device walk. + +- **Never-stale is the invariant everything else serves** - when in doubt escalate to `FullGradleBuild`, because over-building is slow but under-building is wrong. +- **The generation counter is persisted outside the scratch tree** so it survives teardown - never reset it for a test. +- **Session effects belong on the one `QuickBuildSession` thread** (see Session Management above) - injecting `Dispatchers.IO` "to speed it up" breaks ordering with no crash and no failing test. +- **Every new suspending path must re-check its captured session and daemon epoch** before applying its result, or stale work clobbers a fresh session. +- **Wire names are frozen** - renaming a Firebase event, a bench field or a flag file invalidates the benchmark history, and a breaking `setup.json` shape change needs its schema version bumped or CoGo misreads the file instead of invalidating. +- **The daemon strips `final` off classes before dexing** ([`FinalStripper`](daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/dex/FinalStripper.kt)) - not an optimization: a `final` user class cannot be extended by its generated proxy. +- **Do not move the scratch tree next to the project** for tidiness; that puts it back on FUSE and gives back most of the warm-edit gain (see Build Scratch above). +- **Activity proxies override `getClassLoader()` on purpose** - both template crashes seen during development were violations of this one rule. +- **The runtime is Java-only, with no androidx and no CoGo dependencies** - it is compiled into someone else's APK, so a convenience dependency here ships in a user's app. + +### What to Rebuild After a Change + +Everything ships as an APK asset - **there is no push-a-jar shortcut for any component.** `./gradlew :app:assembleV8Debug` plus reinstalling CoGo rebuilds all of them. Then: + +| You edited | Also needed | +| ------------------------------------------ | ------------------------------------------------------------ | +| `:quickbuild:core`, `:quickbuild:protocol` | nothing further - it is CoGo code, and both protocol sides move together | +| `:quickbuild:daemon` | restart the session; the stager re-extracts the daemon dir every provision | +| `:quickbuild:runtime` | **restart the Quick Build session for the project** - the AAR is compiled *into* the proxy app, so reinstalling CoGo alone changes nothing in the running app | +| `:gradle-plugin` | restart CoGo, which re-copies `cogo-plugin.jar` on app start | +| daemon-only iteration | `:quickbuild:daemon:stageDaemon` produces a runnable `build/daemon/` layout - what the harness points `--daemon-jar` at | + +## Known Limitations (v1) + +| Limitation | Impact and status | +| ------------------------------------------------------------ | ------------------------------------------------------------ | +| **The API 28/29 resource-swap path has never run on a device** | Android 9/10 take the legacy `addAssetPath` shim instead of `ResourcesLoader`. Only its failure branch is JVM-tested; the success path needs a real 28/29 device and none of our test devices is one `[unverified on device]`. Candidate for closing it: a targeted instrumented test on the farm's `SM_J737A` (API 28, arm32). | +| **A deleted asset stays readable until the next proxy app rebuild** | The API 30+ asset overlay (a `DirectoryAssetsProvider` on the shared `ResourcesLoader`) can add and replace but cannot hide baked-in assets, so new and modified assets live-reload while a deletion lands only on the next proxy app rebuild. Content an app read and cached before the recreate stays stale until its process restarts, same as resources. On API 28/29 nothing serves a deployed asset payload, so asset-bearing edits route to the standard Gradle build instead - never stale, at full-build cost `[unverified on device]`. | +| **A Gradle 9 start-up failure, contested and never re-run** | The setup build threw `UnknownPluginException` from CoGo's init-script plugin injection against a Gradle 9 project. This was **not** an incidental one-off: the corpus work isolated the variable, substituting Gradle 9.5.1 into `gradle-plugin`'s own AGP 8.11.0 fixture and reproducing the same failure, and concluded it blocks the setup build for **any** project pinned to Gradle 9+. Against that, `AndroidIDEInitScriptPluginTest` is now parameterized on 8.14.3 and 9.5.1 and passes. So either the wall is fixed or the TestKit fixture does not reproduce real injection against a real multi-module project - **no Gradle 9 project has been re-tried since the test went green** `[unverified]`. Matters beyond the corpus: sora-editor pins Gradle 9.5.1 / AGP 9.2.1 and KISS pins 9.4.1, and new projects increasingly pin 9. | +| **A library-module edit takes a full rebuild and an install tap** | ~25 s plus an install tap, against ~2.55 s for an app-module edit measured the same way - both from an earlier pass, not the one in the table above `[measured on a56, earlier pass]` (the 2026-08-11 pass medians 1094 ms for an app-module edit); the prompt fires per out-of-scope edit rather than once per session. Every module's `src` stays watched, so nothing is silently dropped. | +| **A Kotlin/Java corpus failure the tests contradict** | `IncrementalCompilerTest` compiles the same cycle cleanly, yet a sora-editor corpus run failed on this axis. A cross-*module* relationship would route to a rebuild anyway, which may be what was really seen. Not re-run `[unverified]`. | +| **Quick Build needs more RAM than CoGo itself** | Works on both 4 GB-tier devices we own; at 1.9 GB it never provisions, and what fails is the Gradle build every session starts with `[measured on itel]`. The live reload loop has never failed on its own at any tier. Detail: [`docs/low-spec-devices.md`](docs/low-spec-devices.md). | +| **Room-template apps cannot build offline at all** | A CoGo bundle dependency gap that fires before Quick Build is involved, so it is a bundle fix, not one here. The worst gap for an offline-first product `[measured on a56]`. | +| **The Compose template's edit loop is unmeasured** | Never timed `[unmeasured]`, and the full-corpus run that backed the corpus-wide claim is no longer retained - so "Compose is covered" is currently unevidenced. | +| **Cert-pinned services need their console updated** | A service pinned to a signing SHA (Maps keys, Sign-In) rejects this device's CoGo debug cert until the user registers that SHA. User-fixable per service. | +| **A resource aapt2 rejects blocks every save until it is fixed** | The relink links the whole `res/` tree, so one unlinkable resource fails every later build - pure-code saves included. Never-stale holds: nothing is deployed and the diagnostics show every time. Both halves are now handled - self-escalation, plus a `QuickBuildNotice.RELINK_STUCK` prompt `[unverified on device]`. Argument and the deliberate non-fix: [`docs/reliability-gaps.md`](docs/reliability-gaps.md). | +| **A crashing reload has no self-healing, and a between-builds crash is silent** | A reload crash repeats on every reload until the session is reset; the known trigger (resource-id drift on relink) is fixed and the fixed path is device-verified, but the trigger-independent net is missing (the user is told via `QuickBuildNotice.RELOAD_CRASHED`). Separately, the runtime's crash guard reports to CoGo *only while a reload is in flight*, so the proxy app's own organic crash *between* builds is never reported - the user sees only the status-icon color change, not a crash notice (reliability gap #91). `[unverified on device]` Detail: [`docs/reliability-gaps.md`](docs/reliability-gaps.md). | +| **A live service or provider calls OLD copies of recompiled helper classes until its next restart** | The restart closure ([`DeployPolicy.kt`](core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/DeployPolicy.kt)) covers the component's own code and supertypes; a tightening is behind a flag. Surfaced once per session as `QuickBuildNotice.STALE_COMPONENT_HELPERS` `[unverified on device]`. Detail: [`docs/component-proxying-design.md`](docs/component-proxying-design.md). | +| **Forced-tap and daemon-respawn rebuilds over-restart component apps** | Both full-recompile every source, so an app with a service, provider or custom `Application` loses in-app state to an unnecessary process restart even when those classes are byte-identical to what is running. Genuine incremental edits are unaffected. | +| **A `final` library component is skipped** | No user-visible cost and no live-reload coverage lost: it keeps its real manifest name, and the daemon only ever recompiles the project's own sources. Two more are excluded by name. Why, and the mechanism: [`docs/component-proxying-design.md`](docs/component-proxying-design.md). | +| **One `android:process` anywhere costs the whole project Quick Build** | Every save falls back to the standard Gradle build, per project rather than per component - often for a component the user never wrote. Provisioning fails loud and early rather than dropping behavior late, but the only account the user gets is the build log. Why a second process cannot be served: [`docs/component-proxying-design.md`](docs/component-proxying-design.md). | + +## Further Reading + +Design notes live in [`docs/`](docs/); repo-level ADRs are elsewhere, at [`docs/adr/`](../docs/adr/) - the two `docs/` directories are different. + +| Doc | What it covers | +| ------------------------------------------------------------ | ------------------------------------------------------------ | +| [`core/README.md`](core/README.md) | inside `:quickbuild:core` - the ports-and-adapters rule, the packages, and what is unit-testable | +| [`docs/pipeline.md`](docs/pipeline.md) | the class-level map of all eight steps, in pipeline order - read this to find the file that implements a step | +| [`docs/debugging.md`](docs/debugging.md) | why a save did not show up: watch rules, logcat tags, on-device paths, `bench-events.jsonl`, every timeout | +| [`docs/concurrency.md`](docs/concurrency.md) | what runs on which thread or process, the Standard Run contention gates, and what happens when edits arrive mid-build | +| [`protocol/README.md`](protocol/README.md) | the three wire formats - daemon protocol, deploy metadata, build status - and how version skew is handled | +| [`docs/component-proxying-design.md`](docs/component-proxying-design.md) | which components get proxies, the restart closure, the never-proxied list, and the multi-process gap | +| [`docs/low-spec-devices.md`](docs/low-spec-devices.md) | what we measured on 1.4-3.6 GB devices, and why the low-end question is still open | +| [`docs/ksp-kapt-feasibility.md`](docs/ksp-kapt-feasibility.md) | what it would take to run annotation processors in the daemon | +| [`docs/incremental-javac-design.md`](docs/incremental-javac-design.md) | the Java half of the compile and its ABI re-parse | +| [`docs/reliability-gaps.md`](docs/reliability-gaps.md) | the known recovery holes, ranked | +| [`docs/perf-roadmap.md`](docs/perf-roadmap.md) | where the remaining latency is and which levers are worth pulling | +| [`docs/why-not-android-jar.md`](docs/why-not-android-jar.md) | why interception is manifest proxies + `ResourcesLoader` and not a patched `android.jar` | + +Three things live outside this repo: + +- **The benchmark corpus, harness and results**, in the standalone `CodeOnTheGo-build-benchmark` repo - every `corpus/...` path above maps into it. It drives CoGo only through the declared interfaces, so it cannot mask a break in them. Methodology and the QA records (low-spec runbook, template sweep, commit survey) are there too. +- **History** - earlier revisions of these docs in the archived tag `adfa-4128-history-20260731`, design history in Jira ADFA-4128. diff --git a/quickbuild/docs/component-proxying-design.md b/quickbuild/docs/component-proxying-design.md new file mode 100644 index 0000000000..6d237380fe --- /dev/null +++ b/quickbuild/docs/component-proxying-design.md @@ -0,0 +1,290 @@ +# Component proxying + +The generated proxy app names generated `Proxy` classes in its manifest instead of the +user's own. This page says why, which Android components that covers, how the Gradle plugin builds +it, and the constraints a change here must not break. It is the design as built (ADFA-4128, the +initial implementation) - not a change to something already shipped. + +## Why proxy at all + +- **The user's classes are deliberately absent from the installed APK.** They travel only in the + swappable payload dex, so the parent-first classloader chain can never serve a stale copy of a + class the user just edited. +- **But Android instantiates manifest components by class name**, and the manifest is fixed at + install time. Changing it means reinstalling - the cost Quick Build exists to avoid. +- **So the manifest must name a class that is in the APK and never changes**, while the code behind + that name changes on every reload. A generated `Proxy extends ` is that name; + `QuickBuildAppComponentFactory` instantiates it through the current payload generation's loader. +- **The proxy is compiled once, at proxy app build time**, and bundled into every payload dex. Its + `extends` is a *symbolic* reference resolved by name at load time, which is why the same compiled + proxy keeps working as the user's class changes underneath it. +- **The proxy is also where the runtime injects behaviour.** Activity proxies carry a + `getClassLoader()` override, so by-name resolution - Fragment and Navigation + destinations, `LayoutInflater` custom views - can see payload-only classes; service proxies + register with the runtime's live-service census. Receiver and provider proxies are empty + subclasses: they exist for the stable name alone. + +## What has to be proxied + +Android instantiates five kinds of class by name from the merged manifest: + +| Manifest element | Android class | Proxied | Why / note | +|---|---|---|---| +| `` | `android.app.Activity` | yes | Gains the `getClassLoader()` override | +| `` | `android.app.Service` | yes | Registers with the live-service census; swaps by process restart | +| `` | `android.content.BroadcastReceiver` | yes | Manifest-declared only - receivers registered at runtime are ordinary objects and need nothing | +| `` | `android.content.ContentProvider` | yes | Swaps by process restart | +| `` | `android.app.Application` | **no** | Keeps the user's FQN, which `instantiateApplication` resolves against the payload loader like any other component. A proxy would buy nothing: the runtime's own per-process hook (`QuickBuildRuntime.install`) already runs inside `instantiateApplication`, so there is no behaviour to inject via a subclass | + +`` is not instantiated itself, but its `targetActivity` must follow the activity it +points at, or the alias would reference a component the manifest no longer declares. + +The `Application` is instantiated exactly once per process and never re-instantiated, so an app +that declares one restarts the process on every code-bearing deploy rather than hot-swapping. +See "Restart vs recreate". + +## How: a Gradle plugin rewrites the merged manifest + +`QuickBuildPlugin` transforms AGP's merged-manifest artifact: every component's `android:name` +becomes a generated proxy FQN, a `Proxy extends ` source is generated and +compiled into the APK, and `` gains the runtime's `android:appComponentFactory`. +For each proxied activity the transform also synthesizes an `` under the +activity's REAL class name, pointing at the proxy - so an explicit in-app +`Intent(ctx, SomeActivity::class.java)` still resolves instead of throwing +`ActivityNotFoundException` (`QuickBuildManifestTransformer.transformActivities`). The alias +copies its target's `android:exported` verbatim (absent reads as `false`): the alias is the +only manifest entry left under the real name, so pinning it to `false` would reject a launch +that works under a standard run - a pinned shortcut or a share target the app published records +that real name, and the launcher is a different uid. +Everything else - permissions, icon, label, intent filters, `exported`, meta-data - is preserved +verbatim. A manifest *change* (adding a component, editing an intent filter) still needs a proxy +app rebuild; see +[the boundary](../README.md#edit-types-that-can-live-reload). + +Subclassing works rather than delegation because the proxy and the user class both travel in the +payload dex, so a reload swaps the whole hierarchy at once. + +```mermaid +flowchart LR + subgraph build["Proxy app build (Gradle plugin)"] + MM["merged manifest"] --> TR["manifest transformer
android:name -> proxy FQN"] + TR --> GEN["generated Proxy-N-Service / Receiver / Provider
extends the user class"] + TR --> SJ["setup.json
components + supertype chains"] + end + subgraph device["On device"] + GEN -. compiled into the APK .-> FAC["AppComponentFactory
instantiateService / Receiver / Provider"] + FAC --> PL["payload loader
current generation"] + SJ -. read by CoGo .-> DP["DeployPolicy
restart or recreate"] + end +``` + +## Alternatives, and why this one + +| Approach | Why not | +|---|---| +| **No proxy: leave the user's own class names in the manifest** and let `AppComponentFactory` load them from the payload | Loads fine - this is exactly what the `Application` does today. What it loses is the injection point: no `getClassLoader()` override (so `LayoutInflater` and Fragment/Navigation by-name resolution cannot see payload-only classes), no live-service census. For a receiver or provider, which need none of those, the no-proxy option is genuinely close - they are proxied for uniformity. | +| **Delegation: one generic proxy per component type that forwards to a user instance** | A component's behaviour is inherited, not forwardable - lifecycle callbacks, `onBind`, `getResources`/theme overrides, and the concrete type that the framework and libraries check with `instanceof`. Subclassing keeps the real type. | +| **Rewrite the manifest on every reload** | A manifest change means a reinstall. That is the cost Quick Build exists to remove. | +| **Redefine classes in place (Apply Changes / HotSwap style)** | ART's redefinition cannot add or remove classes, methods or fields, so adding a class or a method - routine while developing - falls back to a full build anyway. | +| **Post-process the built APK inside CoGo instead of using a Gradle plugin** | The merged manifest, the variant's dependency artifacts and the compile classpath only exist inside the Gradle build. Doing it outside means re-implementing manifest merging and losing incrementality. | + +**Why the Gradle plugin.** It is the only place with the merged manifest as a first-class artifact +and the variant's real classpath, so proxy generation is an ordinary incremental task rather than a +bolt-on; CoGo already injects Gradle plugins by init script, so it needs no new seam; and everything +it produces (`setup.json`, the proxy sources, the payload dex) is a declared task output that Gradle +caches and invalidates for us. + +## What the proxy app must satisfy + +Every decision above is trying to preserve these. When a change forces a trade-off, trade in this +order - 1 and 2 are not negotiable against the rest. + +1. **It never silently runs stale code.** Every edit either live-reloads or visibly falls back to a + real Gradle build. This outranks speed: a fast wrong answer is worse than a slow right one. +2. **It behaves like the real app.** Same `applicationId`, permissions, icon, label, intent filters + and components; real resources; the merged manifest preserved verbatim apart from component + names. Where it cannot, the difference is written down and surfaced to the user - + [the boundary](../README.md#edit-types-that-can-live-reload) and the + README's Known limitations. **A divergence nobody documented is a bug, not a limitation.** +3. **New classes, resources and assets plug in quickly and reliably.** Generated component names are + stable across generations, so a reload never needs a manifest change; user classes travel in a + swappable payload dex, resources through a replaceable loader. +4. **A reload never reinstalls.** The install is paid once, at provisioning. That is where + seconds-instead-of-minutes comes from, so any design that reinstalls per edit has lost the point. +5. **It never strands the user's real app.** One install slot under the real `applicationId`, + confirm-on-switch, and a completed Standard Run hands back to a live session - the user can + always get back to an ordinary build. +6. **It runs standalone.** With CoGo not attached the proxy app still starts and runs the newest + payload it persisted, rather than silently reverting to the baseline. +7. **It stays cheap to embed.** The runtime is compiled into the user's app, so it is Java-only with + no CoGo dependencies - it must not drag `kotlin-stdlib` or anything else into someone's APK. +8. **Getting from the running app back to the next edit is smooth.** Today the OS app switcher is + how the user returns to CoGo, and build failures are narrated into CoGo's Build Output pane + (`QuickBuildOutputLines.kt`) rather than jumped to; the review-to-edit loop is where the next + round of work goes. (An earlier tap-to-jump mechanism was removed on this branch.) + +1-7 hold today. 8 is partial by design. + +## Key decisions + +- **Every component is proxied by default - user code and library code alike.** The transform never + discriminates by origin; every exception comes from the resolver below, which decides from the + class file rather than from whose code it is. +- **A component that defeats `extends` is never silently dropped.** A `final` library class is + skipped and logged, keeping its real manifest name. One present only on the runtime classpath + cannot be detected before compilation, so it fails the proxy app build loudly, naming the + component and pointing the user at Run/Debug. + + **Skipping a `final` component costs nothing, in either direction.** Nothing changes for the + user: the component keeps its real manifest name and the framework instantiates it exactly as + in an ordinary app. It also costs no live-reload coverage, because the daemon only ever + recompiles the project's own sources - a skipped library component is never one the user could + have edited `[inferred]`. And it cannot be fixed by trying harder: a proxy *is* a generated + subclass (`Proxy extends `), and a `final` class cannot be extended, so + covering it would mean rewriting library bytecode inside an offline on-device build - which + buys nothing given the two lines above. Because + [`ComponentProxiabilityResolver`](../../gradle-plugin/src/main/java/com/itsaky/androidide/gradle/quickbuild/ComponentProxiabilityResolver.kt) + reads `ACC_FINAL` off the variant's dependency artifacts at manifest-transform time, a future + `final` library component needs no CoGo release; a class it cannot find there is assumed + project-owned and proxied. +- **`ComponentProxiabilityResolver` is the single authority on which components get proxied.** + Both the manifest transform and the payload dex task ask it. It reads each component's class + from the variant's dependency artifacts and skips any `final` one - from any library, named + nowhere - leaving it under its real manifest name. Only what a class file cannot reveal is + listed by name: androidx `InitializationProvider` (resolves itself by hardcoded name), + `ProfileInstallReceiver` (absent from some proxy compile classpaths, and absence is + indistinguishable from project-owned before compilation), and Firebase's + `ComponentDiscoveryService` (an ordinary non-final class the SDK never instantiates - it reads + its own `` by that exact name, so renaming it silently disables SDK discovery). Reasons live in that class's KDoc. +- **Provider authorities pass through verbatim.** The proxy app installs under the project's real + `applicationId`, so `${applicationId}` resolves exactly as the real app's would. +- **Unsupported attributes fail the build with the component and attribute named** - no stripping, + no silent loss. `android:process` and isolated/multi-process providers are the live cases. +- **The runtime persists the newest payload to disk.** Providers and the `Application` + instantiate before the binder connects and are never re-instantiated, so without it they would + pin to baseline code forever - a never-stale violation. A fingerprint mismatch or read failure + falls back to gen-0. + +## Restart vs recreate + +Decided per app, not per edit: if the manifest declares a service, provider or custom +`Application`, every code-bearing deploy restarts the process. + +```mermaid +flowchart TD + C["code-bearing deploy"] --> Q{"does the app declare a
service, provider, or a
custom Application?"} + Q -->|no| R["activity recreate
(hot swap)"] + Q -->|yes| K["process restart:
persist payload, ack,
background, exit,
CoGo resumes the task"] + RES["resource-only / asset-only"] --> R +``` + +**Why not key on the recompiled set.** It was tried, and it is wrong. A payload is never a +delta: `DexTool.dex` walks the compiler's whole output tree, so every generation ships every +user class and a hot swap re-defines all of them through a fresh loader. The held instance - +the `Application`, a live service, a provider - keeps the previous class, and the first cast +across the two throws `ClassCastException: Foo cannot be cast to Foo`. A rule that asks +whether the edit *named* the component therefore passes on exactly the edits that break the +app: measured on an A56, an edit to a string literal in an activity's `onCreate` crashed a +probe app that declares its own `Application` +(`spike2-repro-restart-jvmti-2026-08-20.md`). + +Keeping every class in place instead - JVMTI `RedefineClasses`, or compile-time dispatch +injection - is the real answer and is deferred to a follow-up; see +`hot-swap-correctness-plan-2026-08-20.md`. + +- **Receivers are deliberately not in the restart set** - manifest receivers are instantiated + fresh per delivery, so they already run current code. +- **Nor are the components CoGo injects.** `LogSenderPlugin` puts the logsender AAR into every + debuggable variant, so its `LogSenderService` and `LogSenderInstaller` reach the policy in + every app - and without an exemption every app would restart on every save. They are safe + because they ship in the base APK dex and never enter a payload, so no generation redefines + them; the exemption is keyed on those two exact class names for exactly that reason, since a + library class that *did* land in the payload would still be redefined. One predicate + (`ComponentInfo.isRestartSensitive`) applies it to both the restart decision and the + stale-helpers notice. See `live-reload-alternatives.md`. +- **The restart is honest, not clever**: the process really dies and reboots from the persisted + generation, reusing the never-stale catch-up path rather than inventing one. +- **It is cooperative, so the user keeps their place.** Before killing, the runtime moves its own + task to the back and waits for Android to capture the top activity's state + (`RestartHandoff`); CoGo then relaunches with the launcher's own intent, which resumes the + surviving task. The user comes back to the screen they were on, with that screen's saved state + and the back stack. Measured on an A56: 586 ms against 563 ms for a launcher relaunch that + loses all three, 3 of 3 replicates. Two things follow from this and are easy to undo by + accident: + - **The kill must come from inside the app.** CoGo binds the app's keep-alive service to hold + it out of the cached-app freezer, which also holds it out of the killable bucket, so + `am kill` reports success and leaves the process running (3 of 3 on an A56). + - **The relaunch must be the launcher's intent, not an explicit component.** An explicit one + means "start this screen", and against a just-killed task it was delivered to the dead top + record and dropped, leaving the app down in 2 of 8 restart deploys. + - What is still lost is state outside `onSaveInstanceState`, and, for anyone debugging, the + attached session: the process is gone, so the debugger detaches. +- **Skew guard.** An older installed baseline whose baked runtime predates restart support would + ignore the restart flag and hot-swap - stale. `setup.json`'s top-level `schema` field gates it: + below schema 2, a restart-requiring deploy routes to a full proxy app rebuild, which + self-heals by regenerating a schema-2 baseline. +- **Accepted residual**: a *live* service or provider keeps calling old copies of recompiled + non-component helper classes until its next restart - a loader swap updates instantiation, not + live object graphs. Same kind as an activity mid-recreate; see README "Known limitations". + +## Where the code lives + +| Concern | Code | +|---|---| +| Manifest rewrite, authority recording | `gradle-plugin/.../QuickBuildManifestTransformer.kt` | +| Which components can be proxied (the one authority) | `gradle-plugin/.../ComponentProxiabilityResolver.kt` | +| Proxy source generation | `gradle-plugin/.../ProxySourceGenerator.kt` | +| `setup.json` shape and schema version | `gradle-plugin/.../QuickBuildJson.kt`, read by `quickbuild/core/.../data/ProxyAppInfo.kt` | +| Supertype chains recorded per component (written, not currently read - the restart rule no longer needs them) | `gradle-plugin/.../SupertypeResolver.kt`, `domain/ClassHeader.kt` | +| Restart-vs-recreate decision | `quickbuild/core/.../domain/DeployPolicy.kt` | +| Component instantiation on device | `quickbuild/runtime/.../QuickBuildAppComponentFactory` | +| Payload persistence across process death | `quickbuild/runtime/.../PayloadPersistence` | + +Tests sit beside each of those; `DeployPolicyTest` is the one to read first, since it pins the +restart rule and the skew guard. + +## Known gaps + +- **Multi-process components are unsupported** - per-process payload and generation coherence is + unverified, so `android:process` fails the build rather than deploying something unproven. + + **One `android:process` anywhere costs the whole project Quick Build.** The switch is per + project, not per component, so a single component in a second process - often one the user + never wrote, pulled in by a library - turns live reload off for the whole app and sends every + save through the standard Gradle build. Why a second process cannot be served today: a reload + delivers one payload into one process and swaps that process's classloader, so a component + living elsewhere would keep executing the baseline dex - half the app new, half of it old, + which is precisely the staleness Quick Build guarantees cannot happen. Serving it needs a + second delivery channel, a second baseline and generation to track, and a restart closure + spanning processes; none of that exists. + + The failure is loud and early rather than a late loss of behavior: + [`QuickBuildManifestTransformer`](../../gradle-plugin/src/main/java/com/itsaky/androidide/gradle/quickbuild/QuickBuildManifestTransformer.kt) + rejects a non-blank `android:process` on any component (and `android:isolatedProcess` on a + service, `android:multiprocess` on a provider), naming the component and pointing at Standard + Run, so provisioning fails outright. What the user does *not* get is an in-app explanation: + `QuickBuildNotice` has no case for this fallback (verified against the enum - it carries + `BUILD_CANCELLED`, `RELOAD_CRASHED`, `STALE_COMPONENT_HELPERS`, `RELINK_STUCK` and + `PROXY_APP_WONT_STAY_UP`, none of which fit), so the only account of it is the build log + `[inferred]`. + + Evidence: corpus app `notes` on both devices + (`corpus/results/20260725T161105Z-e2e-bench/notes__provision.logcat.txt`). Its *standard* + build succeeds on the A56, which proves this is a Quick Build limitation and not an app defect + `[measured on a56, measured on c107]`. +- **A runtime-only library component still has to be excluded by name.** The manifest transform + searches the variant's DEPENDENCY artifacts, which resolve without compiling anything; + `variant.compileClasspath` cannot be used there, because it drags in `processResources` (for + the project's own R jar), which needs the very manifest this task produces - a real cycle, + reproduced on demand by the mutation in `QuickBuildProxyAppBuildTest`'s KDoc. The cost of the + narrower view: a class it cannot find is either project-owned or runtime-only, and nothing at + that point distinguishes them, so the runtime-only case stays a named entry. +- **Renaming or moving a proxied component class is untested, and proxying may make it worse than + no-proxy would.** The payload's proxy classes are the ones compiled at proxy app build time, so + `Proxy0Activity extends com.foo.MainActivity` keeps resolving that name at load time - fine while + the class merely changes, but a *rename* removes the target. Under proxying the manifest does not + mention the user class, so the edit stays on the live reload path; without proxying it would have + edited the manifest and correctly forced a Gradle build. Nothing in the code detects this today + and no test or device walk covers it. Needs a device repro before we claim either way. +- **Tightening the live-instance residual** - restart on any code deploy while a tracked service + is live - is possible behind a flag (the service census exists). Price it with metrics first. diff --git a/quickbuild/docs/concurrency.md b/quickbuild/docs/concurrency.md new file mode 100644 index 0000000000..ab1e231a75 --- /dev/null +++ b/quickbuild/docs/concurrency.md @@ -0,0 +1,176 @@ +# Quick Build concurrency and contention + +One thread decides everything; every expensive thing runs in another process. That is the whole model. `[inferred from code]` + +| Runs on | What runs there | Wired in | +| --- | --- | --- | +| One `QuickBuildSession` thread | the reducer, every session effect, the orchestrator's bookkeeping, the generation counter, watcher batch delivery | [`QuickBuildModule.kt`](../../app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt) (`newSingleThreadExecutor`) | +| `Dispatchers.IO` | daemon process I/O, the watcher's mtime poll sweep, the install call | [`DaemonProcessClient`](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt), [`AndroidProjectWatcher`](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/AndroidProjectWatcher.kt) | +| A child JVM (the daemon) | incremental Kotlin compile, `javac`, `d8`, `aapt2` relink | [`DaemonMain`](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/DaemonMain.kt) | +| CoGo's tooling server | the proxy app's real Gradle builds (prebuild, provision, rebuild) | [`GradleQuickBuildProvisioner`](../../app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt) | +| The proxy app process | applying the payload and reporting the generation it now runs | [`:quickbuild:runtime`](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/) | + +```mermaid +flowchart TB + subgraph session ["One QuickBuildSession thread - decides everything, blocks on nothing"] + reducer["SessionReducer
total: an unhandled (state, event) is a no-op"] + effects["runEffect
launched, never inline - one thread keeps them ordered"] + orch["Orchestrator bookkeeping
pending set, build ids, one build in flight"] + gen[("GenerationTracker
persisted before hand-out, monotone")] + end + + subgraph io ["Dispatchers.IO - holds no session state"] + daemonIo["Daemon stdio
DaemonProcessClient"] + watchIo["Watcher mtime sweep"] + installIo["Install call"] + end + + daemonProc["Compile daemon, child JVM
kotlinc / javac / d8 / aapt2 - one request in flight"]:::ext + gradleProc["CoGo tooling server
the proxy app's real Gradle builds"]:::ext + appProc["Proxy app process
applies the payload, reports its generation"]:::ext + + orch --> effects + effects -- "suspend, never block" --> daemonIo + effects --> installIo + effects --> gradleProc + effects --> appProc + daemonIo <--> daemonProc + watchIo -- "coalesced batch" --> reducer + reducer --> gen + + daemonProc -. "return value -> OrchestratorEvent" .-> reducer + daemonProc -. "death listener -> DaemonDied" .-> reducer + appProc -. "crash / reconnect flows,
collected on the session scope" .-> reducer + + classDef ext stroke-dasharray: 6 4,stroke-width:1.5px +``` + +Every dotted edge is a result **hopping back onto the session thread**. Nothing outside that box ever touches session state, which is why there are no locks around it. + +**What is single-threaded, and why.** All session state - the reducer's state, `live`, both epochs, the generation counter - is touched only on the session thread, so there are no locks around it and no interleavings to reason about ([`QuickBuildSessionManager`](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManager.kt)). Three properties depend on that thread being *one* thread: + +- **Effects are launched, not run inline.** `runEffect` launches each effect so a dispatch can never re-enter itself; the launches still land in order because there is one thread. Swap in `Dispatchers.IO` and ordering breaks with no crash and no failing test (README, invariant 3 of [Areas to Be Careful Of](../README.md#areas-to-be-careful-of)). +- **The reducer is total.** An unhandled `(state, event)` pair keeps the state and produces no effects ([`SessionReducer`](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/SessionReducer.kt)), so a late, duplicate or out-of-order event is a no-op rather than a corrupt session. Every guard below can therefore be "drop it" instead of "unwind it". +- **Nothing on that thread may block.** Every outward call is `suspend`; the daemon client hops its process I/O to `Dispatchers.IO` and the watcher runs its stat sweep there. A blocking call added here stalls the whole session. + +**What is farmed out, and how results come back.** The session thread never compiles anything. Each build is one suspending pass through [`LiveReloadExecutorImpl`](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImpl.kt) - compile, dex, relink, deploy, strictly in order, each step one request to the daemon. The daemon holds **one request in flight** (`requestMutex`) and its own loop is single-threaded on purpose, so the pipeline is serial end to end; a request that exceeds `requestTimeoutMillis` (300 s) comes back as a failed reply rather than an exception. Results re-enter the model three ways, all of them hopping back onto the session thread: the executor's return value becomes an `OrchestratorEvent`, which the orchestrator delivers *outside* its own lock and the manager `launch`es into a dispatch; the proxy app's crash and reconnect reports arrive as flows collected on the session scope; the daemon's death arrives as a listener callback that dispatches `DaemonDied`. + +**Quick Build vs Standard Run: two shared resources.** They contend for the device's one Gradle slot and the project's one package slot, and each has an explicit gate rather than a lock. + +| Shared resource | Gate | Behaviour when contended | +| --- | --- | --- | +| One Gradle build at a time | `buildService.isBuildInProgress`, checked as late as possible in [`GradleQuickBuildProvisioner`](../../app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt) | returns `SlotBusy` - a distinct outcome, not a build failure. A parked install retry re-parks without spending its auto-retry budget, because it ran no build and prompted nothing. | +| One Gradle cancellation token | `isUserVisibleBuildInProgress` in `cancelProxyAppBuild` | refuses, so a Quick Build stop tap can never kill the user's Standard Run. | +| The editor's build UI (one listener) | `GradleBuildService.withInternalBuild`, spanning the *await* | a prebuild at project open does not drive the status line, the first-build notice, or relabel the Run button. There is no separate acquire/release pair: a leaked acquire would suppress the editor's build UI for the rest of the process, stranding the Run button on the Cancel-build label. | +| One package slot (the real `applicationId`) | [`QuickBuildClobberCheck`](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt), stateless - it re-reads the installed `android:appComponentFactory` on every call | both directions confirm before clobbering; accepting a Standard Run install first tears the Quick Build session down. A foreign signing cert is refused outright ([`RealIdInstall`](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/RealIdInstall.kt)). | + +```mermaid +flowchart TB + qb["Quick Build"] + sr["Standard Run"] + + slot{"isBuildInProgress?
checked as late as possible"} + busy["SlotBusy
a distinct outcome, NOT a build failure - a parked
install retry re-parks without spending its budget
"] + run["Run the Gradle build"] + + cancel{"isUserVisibleBuildInProgress?"} + refuse["Refuse the cancel
a Quick Build stop tap can never kill a Standard Run"] + + pkg{"QuickBuildClobberCheck
stateless - re-reads the installed
appComponentFactory on every call
"} + confirm["Confirm before clobbering
accepting a Standard Run install first tears
the Quick Build session down
"] + refuseCert["Refuse outright
foreign signing cert"] + + qb --> slot + sr --> slot + slot -- busy --> busy + slot -- free --> run + qb -- "stop tap" --> cancel + cancel -- "user's build" --> refuse + qb --> pkg + sr --> pkg + pkg -- "other owner" --> confirm + pkg -- "foreign cert" --> refuseCert + + handback["ANY finished external Gradle build
success or failure"]:::ext + handback -. "marks a live baseline untrusted, or forces
a proxy app rebuild if it removed the daemon's inputs" .-> qb + + classDef ext stroke-dasharray: 6 4,stroke-width:1.5px +``` + +Hand-back closes the loop: *any* finished external Gradle build - success or failure - marks a live session's baseline untrusted, or forces a proxy app rebuild if that build removed the artifacts the daemon reads ([`QuickBuildSessionManager.refreshBaseline`](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManager.kt)). + +**Multiple edits arriving mid-build.** Nothing queues per save and nothing is dropped. + +```mermaid +sequenceDiagram + autonumber + participant U as Saves + participant W as Watcher + coalescing + participant O as Orchestrator
(session thread) + participant D as Daemon (child JVM) + participant A as Proxy app + + U->>W: burst of writes (save-all, git pull, codegen) + Note over W: emit 150 ms after the LAST event,
capped 1 s from the first;
last event per path wins + W->>O: ONE batch, vanished rename-temps dropped + O->>O: pending set MOVED into build #1 + O->>D: compile / dex / relink, serial + + U->>W: more saves arrive mid-build + W->>O: batch 2 + Note over O: never cancels build #1 - it waits.
batch 2 joins the pending set + + D-->>O: result, tagged build #1 + alt build id still current + O->>A: payload at generation N+1 + Note over A: accepted only if STRICTLY newer
than what it runs + O->>O: pending set clears + else superseded (a baseline reset raced it) + Note over O: result discarded, never rendered.
the daemon has no cancel op, so that
compile ran to completion unheard + end + O->>D: build #2, from the pending set +``` + +On failure the batch is unioned back into pending, so the only way a save leaves the set is a build that succeeded with it. + +**The Quick Build tap races its own save.** `[measured on a56, 2026-08-13 manual QA; redesign implemented 2026-08-13, unverified on device]` + +The tap awaits a save-all, then triggers ([`QuickBuildAction`](../../app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt)). The coalescer emits 150 ms after the last event - so at tap time the save is on disk but its batch is still inside the quiet window, and pending is empty. This is deterministic, not a race that sometimes wins: every tap with a dirty buffer sees an empty pending set. Four consequences, all observed in one QA run: + +- the tap routes as a forced `NoOp` - a whole-module blind recompile where an incremental would do; +- the batch (the very files the tap saved) lands mid-build and rebuilds identical bytes behind it (7 echo pairs, 38.9 s of duplicated build time in a 20-minute session); +- the forced path derives its asset list from the (empty) changed set, so it ships no assets - the "redundant" echo build is what actually delivers an asset save; +- a `build.gradle.kts` echo arriving while a rebaseline absorbs the pending set strands unaccounted, and resurfaces on `onBaselineReset` as a spurious `GRADLE_CONFIG_CHANGED` 27 ms after the rebaseline succeeded. + +The redesign (implemented 2026-08-13) keeps the watcher as the **single** changeset source (seeding the tap with saved file names was considered and rejected - a second ingestion path): + +1. The tap carries one bit: whether its save-all wrote anything. Wrote something -> arm the on-deploy switch and let the coalescer's batch drive the one, correctly-routed build. Wrote nothing, pending empty, runtime at the deployed generation -> switch to the app and build **nothing**. +2. The baseline generation becomes monotonic: a rebaseline stamps the next generation from the persistent counter into the proxy APK (a sibling asset of the baseline payload, read the same pre-Context way), instead of [`PayloadStore`](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java)'s constant 0. A post-rebaseline reconnect then reads in-sync by construction, and the reconnect check needs no change. +3. A same-baseline reconnect below the deployed generation (persistence lost) is answered by **re-sending the retained last payload**, not by rebuilding - payloads are cumulative over the baseline, and the session still holds the bytes it last deployed. +4. The forced blind rebuild survives only as last-resort repair: runtime behind and no retained payload to send. +5. A batch arriving during absorption whose files the running Gradle build will read anyway is absorbed with the rest, not stranded. +6. The deferred foreground ask expires: a 34-second-old ask must not beat where the user is now. + +Two bounded non-fixes, deliberate: the watcher's hybrid design (inotify + 2 s mtime-xor-size poll sweep) already bounds a missed event, so the tap needs no repair semantics; and the 1 s coalescer cap can still split a save-all slower than the cap into two builds - rare, self-correcting, and the fix (hold the cap while a save-all is in flight, over the same one-bit channel) is designed but deferred until observed in practice. + +- **Coalesce first.** A burst of writes (save-all, `git pull`, codegen) becomes one batch: emit 150 ms after the last event, capped 1 s from the first, last event per path wins ([`ChangeCoalescing.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/watch/ChangeCoalescing.kt)). [`WatcherBatchReconciler`](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/watch/WatcherBatchReconciler.kt) then drops rename-tool temps that vanished, so one stray file cannot push the batch to a full Gradle build. +- **One build in flight, everything else coalesced.** Starting a build *moves* the pending set into it; the set clears only on success and a failed batch is unioned back ([`LiveReloadOrchestrator`](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestrator.kt)). New work never cancels a running compile - it waits. Only a stop tap abandons a build, and even then the batch returns to pending. +- **Every result carries its build id.** A build whose id no longer matches the in-flight one was superseded (a baseline reset raced it); its result is discarded, never rendered. The daemon has no cancel op, so a cancelled build's compile still runs to completion unheard - it can delay the next build, but cannot deploy. +- **Generations only go up.** [`GenerationTracker`](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTracker.kt) persists a number *before* handing it out, so a crash burns it rather than letting a later session reuse it. The proxy app accepts a payload only if it is *strictly* newer than what it runs ([`PayloadStore`](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java)), which is what makes "an old payload cannot replace a newer one" hold even when a deploy races a reconnect. +- **Two epochs guard every async result.** `sessionEpoch` is bumped by every teardown, and a provision or rebuild that captured the old value discards its result - otherwise a provision completing after "Restart session" would install a zombie session with a live watcher behind an `Idle` UI. The daemon has its own epoch with an exactly-one-transition rule ([`QuickBuildDaemonController`](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt)), so a respawn racing a teardown cannot leave two daemons. +- **Quick builds are suspended while a proxy app rebuild runs.** The pending set is handed to Gradle as absorbed and restored if the rebuild fails; saves that arrive mid-rebuild stay pending and build when the new baseline lands. + +**Where reliability comes from.** Named mechanisms, each with a bounded failure mode: + +| Mechanism | What it prevents | +| --- | --- | +| Reducer totality + single-threaded effects | a late or duplicate event corrupting a session | +| Build-id supersession + the two epochs | stale work applying itself over fresher state | +| Generation monotonicity + the runtime's strictly-newer gate | a stale payload reaching a running app | +| Coalesce-and-union pending set | a save being lost, or one build per keystroke | +| `SlotBusy` as a distinct outcome | Gradle contention reading as a build failure | +| Stateless clobber check | a cached view of the package slot going stale after an install outside CoGo | +| Bounded retries: 2 identical pipeline failures escalate once (latched), 2 foreground install auto-retries | a failing recovery path retrying forever | +| Reconnect catch-up ladder: the runtime restores its persisted payload; a rebaselined APK boots at its monotonic stamped generation; a runtime still reporting `runningGeneration` < `lastDeployedGeneration` gets the retained payload re-sent; a forced rebuild only when retention is missing | a relaunched app silently running old code | + +None of this is device-verified as a set `[unverified on device]`, and the ordering invariants above break without any test going red - see [Areas to Be Careful Of](../README.md#areas-to-be-careful-of). diff --git a/quickbuild/docs/debugging.md b/quickbuild/docs/debugging.md new file mode 100644 index 0000000000..6edc5f4dd2 --- /dev/null +++ b/quickbuild/docs/debugging.md @@ -0,0 +1,342 @@ +# Debugging a live Quick Build session + +You saved a file and the running app did not change. This doc is the answer paths for that, +in the order they are worth trying, plus the reference material each one needs: on-device +paths, the session event log, the adb entry point, and every timeout in the pipeline. + +Assumed already read, and not repeated here: + +- [README, "How to Run On Device"](../README.md#how-to-run-on-device) - the flag files + and the CoGo build you need before any of this works. +- [README, "When a Save Doesn't Show Up"](../README.md#when-a-save-doesnt-show-up) - + the short first-stop triage list and the three logging processes; section 1 below is the + full version of that list. Section 3 here owns the tag convention itself, with the + filters. + +Two flags recur. `CodeOnTheGo.exp` in `Download/` turns Quick Build on at all. +`CodeOnTheGo.qbbench`, alongside it, turns on the session event log and the adb entry point - +sections 5 and 6 need it, sections 1 to 4 do not. + +**Flag files are read once per process.** `FeatureFlags.initialize()` caches on first call and +never re-reads disk, so creating or deleting a flag file changes nothing until CoGo restarts +([`FeatureFlags.kt`](../../common/src/main/java/com/itsaky/androidide/utils/FeatureFlags.kt)). + +## 1. My edit did not show up: work down this list + +Each step names one check and the observable that settles it. Stop at the first one that +answers. + +1. **Was the file watched?** The single most common cause, and it is silent by design. + See section 2 - the rules are narrow and a file one directory outside them produces no + event at all. +2. **Did a build start?** Filter logcat on the session tag (section 3). Every state + transition logs + `Quick-build session: -> on ` + under `QB-SessionManager`. No transition means no batch reached the session. +3. **Did the classifier send it to Gradle instead?** The same line carries the reason: + `... on InvalidationDetected(reason=MANIFEST_CHANGED)` and its eight siblings. That is a + proxy app rebuild, not a live reload - it is slow and it prompts for an install, but it is + the never-stale invariant working, not a bug. +4. **Did the compile fail?** A compile error produces no payload, so the session stays + `Ready` at the old generation and the proxy app shows its error overlay. Look for + diagnostics under `QB-ReloadExecutor`. +5. **Where did the time go?** One line per generation, `quickbuild-e2e:`, section 3. + It is the fastest way to see whether a save reached the device at all. +6. **Is the app on screen actually the proxy app?** A Standard Run install occupies the same + package slot, so both look identical from the launcher. Three markers: + - the installed package declares `android:appComponentFactory` = + `com.itsaky.androidide.quickbuild.runtime.QuickBuildAppComponentFactory` + ([`RealIdInstall.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/RealIdInstall.kt)); + - it logs under the tag `QB-Runtime`; + - it has a `files/quickbuild/payload/` directory once a deploy has landed. + + A Standard Run install has none of the three. + +## 2. Most missing edits were never watched at all + +A dropped event is silent: `WatchFilter.isRelevant` returning false costs nothing and warns +nobody ([`WatchFilter.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/watch/WatchFilter.kt)). + +What is watched: + +- **`/src`, one per discovered module** - recursively, for both inotify and the poll + sweep. Discovery is a walk from the project root bounded to depth 4, skipping `build/` and + dot-directories, keyed on the presence of a `build.gradle[.kts]` + ([`QuickBuildProjectLayout.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildProjectLayout.kt)). +- **Named Gradle files, exactly** - `settings.gradle[.kts]`, `gradle.properties`, + `gradle/libs.versions.toml` at the root, plus `build.gradle[.kts]` per module. Only the + mtime poll covers these; no inotify watch is registered on their parents. +- Generated source roots from `setup.json` are **compiled but deliberately not watched** - + they live under `build/`, which Gradle owns, and watching them would feed the loop its own + output. + +What is dropped before the session ever sees it: + +| Dropped | Rule | +| --- | --- | +| Anything outside a watched `src` tree that is not a watched Gradle file | not under a root | +| Anything with a `build` directory anywhere in its ancestry | build intermediate | +| Names starting with `.` | temp artifact | +| Names ending `~`, `.tmp`, `.swp`, `.bak`, `.orig`, `.rej` | temp artifact | + +A second drop happens later, at batch-settle: a path reported modified that no longer exists +and whose shape names no known role (`sed`'s `sedXXXXXX` and kin) is discarded as rename +noise rather than pushed to a full Gradle build +([`WatcherBatchReconciler.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/watch/WatcherBatchReconciler.kt)). + +Two timing facts that explain a save that showed up late rather than not at all: + +- The project sits on FUSE-backed `/storage/emulated`, which drops inotify events under load. + A **2 s mtime sweep** is the backstop, so a lost event costs up to that. +- A burst settles after **150 ms** of quiet, capped at **1 s** from its first event. + +**`adb push` preserves mtime.** A same-size rewrite pushed that way collides with the poll's +fingerprint and is invisible to it; only inotify catches it, and inotify is the channel that +drops. Touch the file after pushing if a scripted edit seems to have been ignored. + +## 3. One prefix finds every tag: `QB-` + +```bash +adb logcat | grep QB- +``` + +Every Quick Build logger - CoGo's, and the runtime's inside the proxy app - is named +explicitly with a `QB-` prefix, so that one grep is the whole feature and no tag has to be +guessed. Each tag is still usable on its own (`adb logcat -s QB-SessionManager`). To list +the current set rather than trust a doc: + +```bash +grep -rn 'getLogger("QB-' quickbuild app/src/main/java/com/itsaky/androidide +``` + +The prefix exists because CoGo's slf4j binding trims any tag over **23 characters** to its +last 23, overwriting the first two with `..` +([`LogTagUtils.java`](../../logger/src/main/java/com/itsaky/androidide/utils/LogTagUtils.java), +`LogUtils.MAX_TAG_LENGTH = 23`). Class-derived names used to blow that limit and arrive +unsearchable, e.g. `QuickBuildSessionManager` as `..ckBuildSessionManager`. So a new logger +must be an explicit name of 23 characters or fewer, prefix included; `LogUtilsTest` pins both +the trim and the hyphen the prefix relies on `[measured on host]`. + +### The one line worth grepping first + +`QB-ReloadExecutor` emits exactly one structured line per generation +([`E2eTimeline.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimeline.kt)): + +``` +quickbuild-e2e: gen=7 trigger=1234 compileDone=2100 deploySent=2140 reloadLive=2560 compileOrdinal=41 +``` + +```bash +adb logcat | grep 'quickbuild-e2e:' +``` + +Stamps are a monotonic clock in milliseconds. `reloadLive - trigger` is the whole loop the +user feels; `compileDone - trigger` is compile plus dex; `reloadLive - deploySent` is the +binder round trip plus the proxy app's reload. + +`compileOrdinal` is the 1-based compile index within the daemon session, and it is what makes +the durations readable: the same edit costs seconds on a fresh daemon and hundreds of +milliseconds once warm, so **a duration without an ordinal cannot be told apart from +variance**. Read a slow row against its ordinal before calling it a regression. A route that +ran no compile - a resources-only relink - omits the field rather than printing a zero, so an +absent `compileOrdinal` means "no compile ran", never "ordinal 0". + +The line deliberately carries no step timings, spans or other counts - the harness parses it +and widening it further would break that. Those live in `reload_timeline` (section 5). + +### Filter sets + +```bash +# the reload loop only, when everything is too noisy +adb logcat -s QB-Runtime QB-SessionManager QB-Orchestrator QB-ReloadExecutor \ + QB-PayloadDeployer QB-DaemonClient QB-DeployChannel QB-ProxyInstaller \ + QB-DaemonController +``` + +`-s` needs every tag spelled right to show anything, so prefer `grep QB-` unless the volume +is a real problem. + +### The daemon has no log of its own + +It writes `[quickbuild-daemon] ` to stderr +([`DaemonMain.kt`](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/DaemonMain.kt)). +`QB-DaemonClient` drains that and re-logs it as `daemon(stderr): ...` at **warn**, with +non-JSON stdout as `daemon: ...` at **debug**. There is no daemon log file: if CoGo's process +dies, that output is gone. Reproduce it standalone with the same command CoGo uses - +` -jar /quickbuild-daemon.jar`, cwd set to the daemon dir, with a clean +environment. + +## 4. Where Quick Build's files live on device + +`ANDROIDIDE_HOME` is `/data/data/com.itsaky.androidide/files/home/.cg`. Under `run-as +com.itsaky.androidide` the working directory is the data dir, so the relative paths below work +directly. `run-as` needs a debuggable package, which both the CoGo debug build and the proxy +app are. + +**The `run-as` target differs per row.** The persisted payload belongs to the user's app, not +to CoGo. + +| Path | What | How to read it | +| --- | --- | --- | +| `files/home/.cg/quickbuild/quickbuild-runtime.aar` | staged runtime AAR, re-staged every provision | `run-as com.itsaky.androidide` | +| `files/home/.cg/quickbuild/daemon/` | daemon jar, its full runtime classpath, `compose-compiler-plugin.jar`; deleted and re-extracted every provision | `run-as com.itsaky.androidide` | +| `files/home/.cg/quickbuild/bench-events.jsonl` | session event log; bench flag only | `run-as com.itsaky.androidide` | +| `no_backup/quickbuild-scratch/-<16 hex>/work` | executor payload staging | `run-as com.itsaky.androidide`; **deleted on teardown** | +| `no_backup/quickbuild-scratch/-<16 hex>/out` | daemon output: classes, dex, relinked resources | as above | +| `/.androidide/quickbuild/generation` | the generation counter | plain `adb shell cat` | +| `//build/quickbuild/setup.json` | the proxy app build's handshake with CoGo | plain `adb shell cat` | +| `/data/data//files/quickbuild/payload/` | persisted payload: `payload.dex`, `resources.arsc`, `assets.zip`, `meta.json` | `run-as ` | + +Projects live under `/storage/emulated/0/CodeOnTheGoProjects/`. + +Four traps in that table: + +- **The scratch tree is deleted on session teardown**, so inspect it while the session is + live. Its directory name is `-` + ([`QuickBuildScratch.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt)), + so `ls` the parent rather than trying to compute it. +- **The generation counter's directory is `.androidide`, not `.cg`.** CoGo's project cache dir + was renamed to `.cg`; this one path is hardcoded to the old name in + [`FileGenerationStore.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt). + Never reset it to "get a clean test" - the runtime uses it to reject a payload older than + what is running. +- **`resources.arsc` is not a resource table.** It holds the whole relinked resource apk; + the filename is historical + ([`PayloadPersistence.java`](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java)). +- **The baseline is inside the APK, not on disk** - `assets/quickbuild/gen-0.dex`, with the + component name map at `assets/quickbuild/components.json`. + +## 5. bench-events.jsonl is the session as data, and needs the bench flag + +With `CodeOnTheGo.qbbench` present on a **debug** build, two extra listeners run beside CoGo's +shipping analytics sink and append to a JSON-lines file. (The whole benchmark surface lives in +`app/src/debug/`, so a release APK has none of it, flag or no flag.) One object per line, each carrying `"v":1` and a +wall-clock `wallMs` +([`BenchEventsFile.kt`](../../app/src/debug/java/com/itsaky/androidide/quickbuild/BenchEventsFile.kt)). + +```bash +adb shell run-as com.itsaky.androidide \ + cat files/home/.cg/quickbuild/bench-events.jsonl | grep '"state"' | tail -20 +``` + +Seven event types: + +| `event` | Carries | +| --- | --- | +| `session_started` | nothing beyond the envelope | +| `state` | `state`, and `generation` where the state has one | +| `build_started` | `buildId`, `route` | +| `build_finished` | `buildId`, `outcome` | +| `reload_timeline` | the full save-to-reload breakdown, below | +| `rebaseline` | `ok`, `durationMillis` (the Gradle build's wall clock), `relaunchOk`, and - only when `relaunchOk` is true - `toRunningMillis` (rebuild start to the relaunched app's runtime reconnect) | +| `invalidation` | `reason` | + +### Wire names are frozen and do not match the Kotlin identifiers + +The harness string-compares these literals and historical files carry them, so a rename in +code must not change them +([`BenchQuickBuildMetricsSink.kt`](../../app/src/debug/java/com/itsaky/androidide/quickbuild/BenchQuickBuildMetricsSink.kt), +[`BenchStateRecorder.kt`](../../app/src/debug/java/com/itsaky/androidide/quickbuild/BenchStateRecorder.kt)). +Three will trip you up when grepping: + +| In code | On the wire | +| --- | --- | +| state `Prebuilding` | `Prewarming` | +| route `WarmCompile` | `Seed` | +| outcome `RequiresProxyAppRebuild` | `RequiresRebaseline` | + +Everything else serializes under its own name: states `Idle`, `Provisioning`, `Ready`, +`Building`, `Deployed`, `Invalidated`, `Degraded`; routes `CodeOnly`, `ResourcesOnly`, +`AssetsOnly`, `CodeAndResources`, `FullGradleBuild`, `NoOp`; outcomes `Success`, +`CompileError`, `DeployFailure`, `InfrastructureFailure`; and the nine `InvalidationReason` +constants verbatim. + +### reload_timeline, and why the residual is the point + +Five host spans partition the build half - `scanMs`, `compileRpcMs`, `policyMs`, `dexRpcMs`, +`relinkRpcMs`. The daemon's own timings (`kotlinMs`, `javacMs`, `stripMs`, `d8Ms`, +`preSnapMs`, `postSnapMs`, `javaAbiSnapMs`, `aapt2CompileMs`, `aapt2LinkMs`) **nest inside** +those, so they are reported but never summed. + +``` +accountedMs = scanMs + compileRpcMs + policyMs + dexRpcMs + relinkRpcMs + (reloadLive - deploySent) +unaccountedMs = totalMs - accountedMs +``` + +A near-zero residual is healthy. A growing one means a step is running that nothing times, and +the next reader sees the gap rather than misattributing that cost to whatever is measured next +door. A build that measured no spans reports **no** residual rather than blaming the whole +build. Known healthy contributors are small: changed-asset packaging, and payload bookkeeping +before the deploy hand-off. + +Each line also carries the daemon's counters - `nAllSources`, `nKotlinDeclaredChanged`, +`nJavaSources`, `nChangedClasses`, `nClassFiles`, `classBytes` - plus `compileOrdinal` and +`scratchFs`, without which a timing row cannot be read at all. What each one means, and why +those last two are context rather than cost: +[the protocol reference](../protocol/README.md#per-build-statistics-what-the-op-did-not-just-how-long-two-compilers-took). + +`nKotlinDeclaredChanged` is the size of the dirty set the daemon **handed** the Kotlin engine, +not the number of files recompiled. The engine widens that set from its own dependency graph, +so a build can recompile files this number does not count. Reading it as "recompiled" has +already cost one investigation a day. Event feeds recorded before 2026-08-18 spell the same +counter `nKotlinCompiled`, under the old, wrong name. + +Two of those are spelled differently on the two wires, so grep for the right one: the daemon +protocol's `nKotlinToCompile` and `scratchFsType` are written here as `nKotlinDeclaredChanged` +and `scratchFs`. + +## 6. You can start a session from adb, but only under the bench flag + +`QuickBuildBenchActivity` is declared only in the debug manifest, is exported, and is +double-gated on both flags. It opens a project and +fires the first Quick Build tap as the editor initializes, replacing the human's tap. It +accepts only an existing directory inside the projects folder, so a hostile sender can at +worst open one of the user's own projects +([`QuickBuildBenchActivity.kt`](../../app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchActivity.kt)). + +```bash +adb shell am start-activity \ + -a com.itsaky.androidide.quickbuild.action.BENCH_OPEN_PROJECT \ + -n com.itsaky.androidide/.quickbuild.QuickBuildBenchActivity \ + --es com.itsaky.androidide.quickbuild.extra.PROJECT_PATH \ + /storage/emulated/0/CodeOnTheGoProjects/ +``` + +- **Idempotent.** Re-sending for the already-open, already-initialized project just taps Quick + Build again, so a session can be retried (after an install-confirm timeout, say) without a + force-stop and full re-open. +- **Optional `--es com.itsaky.androidide.quickbuild.extra.MODE `**, either `quickbuild` + (the default) or `standard`. `standard` fires the normal Run button instead, which is how a + standard build is measured on the same warm daemon. An unknown value rejects the intent + outright. + +A third flag, `CodeOnTheGo.qbnoseed`, is inert unless `qbbench` is also on. What it does and +why it exists: [README, "How to Run On Device"](../README.md#how-to-run-on-device). + +## 7. Tunables: every timeout and bound in the pipeline + +A hang usually means one of these fired, or did not. All are compile-time constants, not user +settings. Most are the default of a constructor parameter, so a test can drive them; six are +not, and changing those means editing the constant: `MODULE_SCAN_MAX_DEPTH`, `UID_RETRIES`, +`MAX_INSTALL_AUTO_RETRIES`, `SHUTDOWN_TIMEOUT_MILLIS`, `REBIND_MIN_DELAY_MS`, +`REBIND_MAX_DELAY_MS`. + +| Tunable | Where it is read | Default | What changing it does | +| --- | --- | --- | --- | +| Watcher mtime poll interval | `AndroidProjectWatcher.DEFAULT_POLL_MILLIS` | 2 s | Upper bound on how long a save inotify dropped stays unseen. Lowering it re-walks the FUSE-backed project tree more often. | +| Debounce quiet period | `ChangeCoalescingDefaults.QUIET_MILLIS` | 150 ms | Idle gap that ends a burst. Raising it batches more saves into one build and adds that much to every save. | +| Debounce hard cap | `ChangeCoalescingDefaults.MAX_MILLIS` | 1 s | Cap measured from the burst's first event, so a continuous write stream cannot defer a build forever. | +| Module scan depth | `QuickBuildProjectLayout.MODULE_SCAN_MAX_DEPTH` | 4 | How deep the one-time scan looks for modules to watch. A module nested deeper is never watched, so its edits are silently dropped. | +| Scratch free-space floor | `QuickBuildScratch.DEFAULT_MIN_FREE_BYTES` | 100 MB | Provisioning refuses to start below this on the app-private volume, so a full disk fails in seconds instead of as ENOSPC minutes into the Gradle build. | +| Install confirm timeout | `ProxyAppInstaller.DEFAULT_TIMEOUT_MILLIS` | 180 s | How long the session waits for the OS install dialog to be accepted before parking in `Invalidated(awaitingRetry = true)`. | +| Install confirm poll | `ProxyAppInstaller.DEFAULT_POLL_MILLIS` | 1 s | How often the installer re-checks whether the install landed. | +| Install uid retries | `ProxyAppInstaller.UID_RETRIES` | 5 | Attempts at reading the just-installed package's uid, spaced by the poll interval above, covering PackageManager's lag after the install lands. Exhausting them fails the install. | +| Foreground install auto-retries | `SessionReducer.MAX_INSTALL_AUTO_RETRIES` | 2 | How many times CoGo returning to the foreground re-runs an unconfirmed rebuild before it stops re-prompting. | +| 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 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. | diff --git a/quickbuild/docs/incremental-javac-design.md b/quickbuild/docs/incremental-javac-design.md new file mode 100644 index 0000000000..8e081189d6 --- /dev/null +++ b/quickbuild/docs/incremental-javac-design.md @@ -0,0 +1,86 @@ +# Incremental javac in the Quick Build daemon + +Design for making the daemon's javac pass cost-proportional to the edit. **Nothing here is +implemented.** The two guards below are the correctness argument - preserve them if you change +this area. + +The problem: the daemon recompiles every `.java` file in the module on every save, which is how +Quick Build ends up slower than plain incremental Gradle on a Java-heavy app. `sora-editor-full` +(214 `.java` files) runs at 0.34-0.40x on warm edits [measured on a56, earlier pass]; it is still +the only app Quick Build loses on, at 0.76x on its Java-ABI edit in the 2026-08-11 pass +[measured on a56]. + +```mermaid +flowchart LR + subgraph today["Today, every edit"] + A1["all .java in the module"] --> FM1["fresh file manager
re-scans android.jar + every AAR"] + FM1 --> JC1["javac: full recompile"] + end + subgraph proposed["Proposed"] + A2["changed .java only"] --> FM2["session-scoped file manager (B)"] + FM2 --> G{"guards: did a Java ABI
or a Kotlin public API move?"} + G -->|yes, or unknown| JC1 + G -->|no| JC2["javac: changed files only"] + end +``` + +## Where the cost comes from + +- **Every `.java` source is passed on every compile** - javac has no incremental mode of its own. + On a 214-file module a host micro-benchmark puts the per-file path at 308 ms -> 7 ms + [measured on host]. +- **The file manager is rebuilt per compile**, so `android.jar`'s 27 MB zip index and every AAR + jar is re-scanned, even though the session's classpath is fixed for its whole life. Reuse alone + is worth ~24% [measured on host]. +- **Modules with no `.java` pay none of this** - `JavaCompileStep` is never called. + +## The design + +Two independent changes, built in this order behind a new `quickbuild.javac.incremental` flag +(default off): + +1. **B - reuse the file manager across the session.** Small and self-contained. +2. **A - compile only the changed files.** The correctness-sensitive half. + +B goes first because the two risks are disjoint - stale cache versus stale bytecode - so a bug +stays attributable to one of them. Within A, wire the fallback to a full javac before the fast +path, so a guard bug costs speed rather than correctness. + +## The two guards, which are the whole correctness argument + +- **Guard 1: no Java ABI moved.** Already built (`JavaSourceAbi`), already gating the Kotlin side. + It deliberately bakes constant initializer values into the fingerprint, because Kotlin inlines + Java constants into its callers - a constant-value-only edit must not take the fast path. +- **Guard 2: no Kotlin-emitted public API moved.** Not built; this is the remaining work. It has + to diff the pre/post output keysets rather than only the files it rewrote, or a *deleted* Kotlin + class is invisible to it. +- Both fail conservative: unknown means full recompile. + +## Alternatives + +- **ECJ instead of javac** - rejected: a 3 MB dependency, an EPL audit, an incremental builder + that is not a standalone API, and no measured win over A+B. +- **javac `TaskListener` dependency graph** - deferred: it only narrows the ABI-change path, and + its payoff is unmeasurable until A+B set a baseline. +- **Annotation processing** - not a factor: the daemon passes `-proc:none`, and processor-input + edits leave the live-reload path entirely (`ksp-kapt-feasibility.md`). + +## Where the code lives + +| Concern | Code | +| --- | --- | +| Compile orchestration, classpath snapshot, ABI baseline | `quickbuild/daemon/.../compile/IncrementalCompiler.kt` | +| javac invocation and the file manager | `quickbuild/daemon/.../compile/JavaCompileStep.kt` | +| Java ABI fingerprint (guard 1) | `quickbuild/daemon/.../compile/JavaSourceAbi.kt` | +| Current status and post-storage-move numbers | `perf-roadmap.md` | + +## Not done yet + +- Guard 2 - the only real implementation work left. +- Tests. They belong beside `IncrementalCompilerTest` in `:quickbuild:daemon:test`, and must cover + at minimum: a body-only edit rewriting just its class, an ABI edit falling back, a deleted Kotlin + class recompiling its Java callers, and a compile error *not* promoting the ABI baseline. +- The a56 sweep needs `javacMs` populated; today's device split is inferred from the c107 + breakdown plus the host curve [inferred]. +- An ABI-changing Java edit still forces a full Kotlin recompile - a separate problem, tracked as + lever 4 in `perf-roadmap.md`. diff --git a/quickbuild/docs/ksp-kapt-feasibility.md b/quickbuild/docs/ksp-kapt-feasibility.md new file mode 100644 index 0000000000..a600c9af5b --- /dev/null +++ b/quickbuild/docs/ksp-kapt-feasibility.md @@ -0,0 +1,56 @@ +# Can the quick-build daemon run kapt/KSP itself? + +Research only - nothing here is implemented. **Both mechanisms are reachable from the daemon, and +the prize is narrow.** kapt reuses the existing `-Xplugin` route (475 KB jar). KSP2 works too, but +needs an 83 MB standalone jar bundling its own Analysis API session - a lot to ship to the low-end +offline devices this product targets. + +**Next step: one ~1-hour A56 experiment.** Everything else hangs on it. Run a plain JVM as the +app's uid (`adb shell run-as com.itsaky.androidide`, CoGo's bundled OpenJDK), open +`jdbc:sqlite::memory:` through xerial sqlite-jdbc with `org.sqlite.lib.path` pointed at an +extracted `Linux-Android/aarch64/libsqlitejdbc.so`, and see whether the connection opens. + +- If it opens, Room's verifier is not a wall and the choice becomes a plain cost comparison - kapt + first. +- If it doesn't, the fallback is unchanged: processor-touching edits keep going to Gradle, already + a small minority. + +What this would buy: today an edit touching annotation-processor input (a `@Dao`, `@Entity`, +`@Module`) falls off the live reload path and takes a full proxy app rebuild. The shipped +classifier ([`domain/annotations/`](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/annotations)) +already keeps every *other* edit in the same project on live reload. + +| Mechanism | Verdict | Cost `[assumed]` | Why | +|---|---|---|---| +| Java `annotationProcessor` (JSR-269) | cheap | days | runs inside the daemon's in-process javac; needs `-processorpath` + generated-source routing. Covers only Java sources, not a Kotlin-declared `@Entity`, so it doesn't move the needle | +| kapt via `-Xplugin=` | possible | 1-2 weeks | reuses the plugin-jar route, but needs a second kotlinc pass orchestrated alongside the incremental compile | +| KSP2 standalone | possible, heavy | weeks | incremental-aware, but ships 83 MB and does not reuse the daemon's compile session | +| KSP1 as a compiler plugin | gone | n/a | KSP 2.3.6 dropped `symbol-processing-cmdline` | + +## Room's wall is not what it looks like + +`DatabaseVerifier$Companion.create` catches only `java.lang.Exception`, but a failed native load +throws `UnsatisfiedLinkError` - an `Error`. It escapes the catch and kills the processing round +instead of degrading to Room's documented "verification disabled" warning. + +The native itself is fine: `sqlite-jdbc-3.41.2.2.jar` ships a real bionic build at +`org/sqlite/native/Linux-Android/aarch64/libsqlitejdbc.so` (`DT_NEEDED`: `libm.so`, `libc.so`, +`libandroid.so` - not the `libm.so.6` glibc build that failed in a prior on-device attempt). +Selection depends on `OSInfo.isAndroid()`, which the daemon's bundled OpenJDK likely fails +`[unverified, needs a device]` - but `SQLiteJDBCLoader` exposes `org.sqlite.lib.path` / +`org.sqlite.lib.name`, so the daemon can force-load the right native without patching Room or +sqlite-jdbc. + +There is no `room.verifySchema=false` opt-out (only source-level `@SkipQueryVerification`), and KSP +vs kapt makes no difference - both share the same `DatabaseProcessor` path. + +## Known gaps + +- **Version provenance unverified**: 3.41.2.2 is what was in this workspace's cache, not confirmed + as what room-compiler 2.8.4 resolves. +- **Whether kapt's stub pass can share the daemon's incremental caches is unknown** - it is a + separate kotlinc invocation, not a plugin running inside the normal compile the way Compose does. +- **The delivery half is untested**: whether newly-generated classes the baseline dex has never + seen load through live reload at all. Confirm alongside the A56 experiment. +- **How often processor-input edits happen in practice is `[unmeasured]`** - the corpus e2e data + can answer this, and should, before investing in either mechanism. diff --git a/quickbuild/docs/live-reload-alternatives.md b/quickbuild/docs/live-reload-alternatives.md new file mode 100644 index 0000000000..3515241103 --- /dev/null +++ b/quickbuild/docs/live-reload-alternatives.md @@ -0,0 +1,253 @@ +# Live reload + +How a Quick Build code deploy reaches the running app, as shipped. The alternatives we +weighed and rejected are kept as history at the end. Vocabulary - generation, payload, +baseline, proxy app, `setup.json` - is defined in `pipeline.md`; how components are proxied +is in `component-proxying-design.md`. + +## The shipped method + +Every generation ships the **whole** user class set: `DexTool.dex` dexes the daemon's compile +output tree, never a delta, and `PayloadStore.apply` loads it through a **fresh** +`InMemoryDexClassLoader` parented to the APK loader. So a deploy redefines every user class, +whatever the edit was. + +That is what forces the restart rule. An object the app holds across the deploy - a custom +`Application`, a live `Service` or `ContentProvider` - keeps the previous copy of its class, +and the first cast between old and new throws `ClassCastException: Foo cannot be cast to Foo`. +Reproduced end to end on device `[measured on a56, 2026-08-20]`. + +### The restart rule + +A code-bearing deploy **restarts** the proxy-app process when the app declares a +restart-sensitive component - ``, ``, or a custom `Application` - and +**hot-swaps** otherwise. `DeployPolicy` decides it. A save that compiles no code - a resource +or asset edit - never reaches this rule: it follows the resource path (`resource-updates.md`) +and never restarts. + +- The rule keys on what the app **declares**, not on what the compile touched. Keying on the + recompiled set is what let an activity-only edit crash the app: the payload redefines the + `Application` either way. +- Activities and receivers never count. Recreate already refreshes an activity, and a manifest + receiver is instantiated fresh per delivery through the component factory. +- A baseline whose `setup.json` predates schema v2 has no component list and a runtime that + would ignore a restart request, so a code deploy there falls back to a full proxy-app + rebuild rather than hot-swapping something stale. One carve-out: a compile that emitted + nothing deploys nothing that can stale a component, so it recreates instead of paying the + rebuild. + +### The CoGo-component exemption + +**CoGo's own injected components do not count.** Exactly two class names are exempt +(`COGO_INJECTED_COMPONENTS`, `quickbuild/core/.../domain/reload/ComponentInfo.kt`): + +| Class | Kind | +|---|---| +| `com.itsaky.androidide.logsender.LogSenderService` | service | +| `com.itsaky.androidide.logsender.utils.LogSenderInstaller` | provider | + +Without this, **every** app restarts on **every** save: `LogSenderPlugin` adds the logsender AAR +to every debuggable variant, so its service and installer provider land in every app CoGo +builds, and the restart rule saw them as the user's held components. + +Why it is safe: those 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. The AAR reaches the build only as a runtime dependency and a compile classpath +entry - never as a project artifact, and the class divert (`QuickBuildPayloadTransformTask`) +is registered at `ScopedArtifacts.Scope.PROJECT`, which covers the project's own classes only +(`component-proxying-design.md`) - so it is never dexed into a payload. Payload loaders are parent-first with the APK loader as parent, so +every generation's `Proxy0Service` resolves the **same** `LogSenderService` class object. Their +identity cannot change across a deploy, and the crash the restart rule exists to prevent cannot +arise from them. The proxies hold no state of their own: `ProxySourceGenerator` emits an empty +subclass for services and providers. + +Why it is keyed on **exact** class names, and must stay that way: the safety comes from these +specific classes being absent from the payload, not from being "library code". Any library class +that *did* land in the payload would still be redefined per generation, and a package-prefix or +origin-based test would wrongly exempt it. Same shape and same reason as the Gradle plugin's +`ComponentProxiabilityResolver.UNPROXIABLE_BY_NAME`. + +Nothing enforces the premise at build time: no test fails if a future build change lands these +classes in the compile output tree. The tests pin the exemption's behaviour, not the build's; +the absent-from-payload invariant is upheld by review of the logsender and Gradle-plugin +wiring above. + +The exemption applies in **both** consumers of the rule, through one predicate +(`ComponentInfo.isRestartSensitive()`): `DeployPolicy`'s restart decision, and the +`STALE_COMPONENT_HELPERS` notice that fires when a restart-sensitive component merely exists and +the deploy hot-swapped anyway. Exempting only the first would turn every hot swap on an ordinary +app into a spurious warning about CoGo's own logsender. + +### The cooperative relaunch + +A restart deploy does not just kill the process. `RestartHandoff` waits for the app to hand its +state to the system server first, so the user comes back to the same screen, state, and back +stack. + +```mermaid +flowchart LR + persist["Persist gen N+1"] --> background["Ask Android to
background the app"] + background --> stopped["Wait: every activity
reaches onStop"] + stopped --> drain["Wait: main looper drains
past activityStopped"] + drain --> kill["killProcess"] + kill --> relaunch["Resume the task -
boots gen N+1"] +``` + +Both waits are needed, and only the second is the one the server reads: stopping is when +`ActivityThread` *captures* the state, and the `activityStopped` report carrying it is then +posted to the main looper. A message queued behind the stop cannot run before the report the +stop queued, so draining the looper is the proof it landed. + +Killing early is not a cosmetic loss. A process killed while the server still believes its top +activity has no saved state gets that record force-removed - taking the task with it when it was +the only entry, so the relaunch has nothing to resume and the user lands on the launcher with no +Back to their work. `[measured on a56]`: force-removed on 8 of 8 restarts made with the app in +front, against 0 of 5 made with it backgrounded; a clean two-entry stack collapsed to one entry; +and in six consecutive warm saves of that first pass, twice the force-removal left the relaunch +nothing to start, so the save waited out the relaunch retry's deadline and cost 17 s instead +of 2 (the second pass, with this handoff, saw 0 of 6 and 0 of 9 force-removals). The wait is +bounded across both phases - the process is killed either way - so an app that will not stop +delays a restart rather than blocking it. + +### Crash safety + +A generation that crashes is quarantined, and the next boot falls back to the last generation +that actually reached the screen. + +- **Both deploy paths are covered.** A hot swap names the generation awaiting its first frame. + A restart deploy names nothing by itself - it persists and kills, so the fresh process boots + that generation with no reload pending - so `BootProbation` puts a generation adopted at boot + **on probation** until it proves itself. Without that, a bad generation crash-looped on every + launch with no way out `[measured on a56]`. +- **The proof is reaching the screen.** `PayloadPersistence.markGood` records a generation only + once an activity of it was resumed; `good.json` names it. That is the same record a fallback + needs, so proving and falling back share one source of truth. +- **Fallback cannot loop.** `quarantine` refuses to name a generation already recorded good, so + the generation a fallback boots onto cannot be quarantined by the next crash. Blaming too + widely therefore costs a log line, not the user's last working code. If no generation was + ever marked good, the floor is install-time code. + +### What a save costs + +| Phase of a warm save | Hot swap | Restart | +|---|---|---| +| Apply the payload | 10-13 ms | 394-407 ms | + +`[measured on a56, 2026-08-21; 42 paired warm saves across 3 apps, one installed build]`, +per-app medians. The apply phase ends when the process is reconnected at the deployed +generation, so on the restart side it **contains** the relaunch - persist, exit, relaunch, +reconnect - as one fixed ~390 ms step that does not scale with app size. + +End to end, a restart save ran a median **+477 ms** slower than the same save hot-swapped, in +the same pass. The two numbers compose: ~390 ms of the gap is the apply step above, and the +remaining 60-128 ms is the compile running slower with logsender on - a cost of logsender +itself, which the exemption keeps. So the exemption buys back the ~390 ms apply step on every +save; treat that as the firm number. A restart also detaches an attached debugger - a +per-save cost no millisecond figure captures - which the exemption spares the apps that +hot-swap. + +That gap is the whole argument for the exemption. It is the difference between the common case +and the rare one only while logsender is exempt; without the exemption it is what *every* save +on *every* app pays. (History's 586 ms relaunch figure below measured to full screen restore +on the earlier spike build; it overlaps the apply step, it does not add to it.) + +## History: the alternatives we weighed + +Everything below is the decision record from 2026-08-20, kept for the reasoning. It describes +the choice, not the current behaviour. + +### The constraint + +The original method - one `InMemoryDexClassLoader` per generation, replacing all classes - had +two issues: + +- Apps declaring their own `Application`, `Service` or `Provider` crash with `ClassCastException` + when any caller casts across the generation boundary. +- If class A calls class B and only B is replaced, the running A keeps the old B forever. A + class's references are fixed the first time it runs, and no classloader arrangement can update + them `[measured on a56, 2026-08-20]`. Replacing every class sidesteps this, and is exactly what + causes the first issue. **Identity or propagation: a classloader design gets one, never both.** + +### Goals + +- Significantly faster than the standard Gradle pipeline (new APK, install, restart). +- Correctness and reliability. An occasional failed swap is fine if it visibly falls back to a + restart; silent wrong behaviour is not. +- Ideally, debugging keeps working. +- Not every possible app - the target is what people build on CodeOnTheGo, which we aspire to + include moderately complex Android apps. + +### Options + +Restart is the universal fallback whichever option wins: the app relaunches from the +already-installed payload in well under a second, never a reinstall. So each option is really +"how often does a save land without paying that restart". + +- **Replace everything, warn on risk** (the pre-decision behaviour). Keeping it means shipping + the reproduced crash. +- **Restart when the app holds a component.** Today's mechanism plus a relaunch on save for apps + declaring one - 5 of 29 corpus apps. A few lines of code. **Chosen.** +- **JVMTI in-place redefinition** - what Android Studio's Apply Changes uses. Update each running + class in place, so nothing ever has two identities. A device spike proved redefinition works on + target hardware with no host machine involved. Most expensive (~80-120 h expert baseline), + needs a native agent per ABI inside the user's app, and structural edits still restart. +- **Compile-time dispatch injection** - what Instant Run did. A hidden switch in every method, + repointed on reload. Same in-place effect with no native code and no Android version floor, at + roughly half to two-thirds the cost. Google retired the design, and the injected machinery + shows up in stack traces while debugging. +- **ArtMethod entry-point hooking** (Pine/LSPlant-style). Rides unpublished ART internals that + change with each Android release - a break we cannot chase on offline devices - and can + silently miss inlined methods. +- **Delta payloads.** Measured dead on device: running code silently keeps the old version, with + no error raised anywhere. + +| Alternative | Speed and feel on save | Correctness | Debugging | Effort | Key risk | +|---|---|---|---|---|---| +| Replace everything | ~1 s, in place | Fails - reproduced crash | Fine until the crash | None | Ships a known crash | +| Restart on held component | ~1 s; +0.4 s restart for 5 of 29 apps, back to the same screen | Correct | Detaches on each save for those apps | Hours | Restart-path defect to fix alongside | +| JVMTI redefinition | ~1 s, stays on screen for method-body edits | Correct by construction | Best - shares the debugger's interface | ~80-120 h | Native agent packaging, per ABI | +| Dispatch injection | ~1 s, stays on screen for method-body edits | Correct for body edits | Works; injected frames in stack traces | ~50-80 h | Google retired the design; the transform is the hard part | +| ArtMethod hooking | ~1 s, stays on screen | Can silently miss inlined methods | Weakest - contends with the debugger | ~80-120 h | Breaks on Android releases we cannot chase | +| Delta payloads | - | Fails - silent stale code | - | - | Ruled out by measurement | + +Effort figures are expert-baseline estimates; ~1 s is the measured median warm reload. + +### Decision, 2026-08-20 + +Ship the restart rule with the cooperative relaunch. Pursue JVMTI in-place redefinition as a +followup ticket, sizing it against dispatch injection before committing. + +### Why the pre-decision guard missed the crash + +The old `DeployPolicy` restarted only when the *recompiled* set intersected a restart-sensitive +component's closure. Its premise - that only changed classes get new identities - is false +against a whole-tree payload, so the guard passed on exactly the edits that break the app: after +an activity-only edit the recreated activity resolved the `Application` class through generation +N while `getApplication()` still returned a generation N-1 instance, and the cast threw. + +### Evidence + +From a Samsung A56 (Android 16), 2026-08-20 unless noted; full probe logs and drivers live in +the ADFA-4128 working notes (spike 1: cross-generation resolution; spike 2: crash repro, +restart cost, JVMTI). The shipped section's per-save numbers (10-13 ms vs 394-407 ms apply, ++477 ms end to end) are from a later paired pass on the same device, 2026-08-21 03:19-04:22Z, +against one installed build carrying the restart-rule fixes: 42 paired warm saves across 3 +apps, logsender on (every save restarts) vs off (every save hot-swaps), restart classification +read per save from logcat. That pass and its raw events live in the working notes as +`partial-bench-restart-vs-hotswap-2026-08-21`. + +- **Classloader constraint:** 8 loader arrangements, 56/56 probes - every arrangement either + crashes on held objects or silently stops delivering new code. +- **Crash repro:** one activity-only edit in a real proxy app crashed it twice (quarantine + rebooted onto install-time code, the re-sent batch crashed again), ending at the system error + dialog. +- **Restart cost:** restart step median 376 ms vs 13 ms hot swap. Cooperative relaunch + (background so saved-state capture runs, kill, resume the task) restored screen, state and back + stack in 586 ms vs 563 ms for the launcher relaunch - which starts the launch activity fresh, + restoring neither screen, state, nor back stack, so the 23 ms it saves buys nothing; direct + relaunch of the top activity is faster (319 ms) but returns a fresh instance in a + single-entry task. +- **JVMTI:** a native agent redefined a method body host-free, including a class loaded via the + same in-memory dex path the runtime uses, already called before the redefine. Minimal + capability (`can_redefine_classes` alone) succeeds where broader requests are refused; the + payload is a per-class dex. diff --git a/quickbuild/docs/low-spec-devices.md b/quickbuild/docs/low-spec-devices.md new file mode 100644 index 0000000000..bfe01e895d --- /dev/null +++ b/quickbuild/docs/low-spec-devices.md @@ -0,0 +1,132 @@ +# Low-spec devices: on-device Gradle is the wall, not Quick Build + +**CoGo itself runs on 2 GB devices, and people use it there** - Hal reports community members +doing so on 32-bit 2 GB hardware (ADFA-4929). **What we have not got working at that tier is the +full on-device *Gradle* build every Quick Build session must start with.** We never watched one +fail: all three itel attempts ended at a timeout we chose, so "too slow for us to wait out" is the +honest claim, and whether it would finish given longer is unmeasured. Nothing measured implicates +Quick Build's own runtime - the live reload loop has never failed on its own at any tier tested, +and has never been tested at 2 GB because provisioning never got far enough to start it. + +**Decided (Bryan, 2026-08-05): the 4 GB tier is the target; 1.9 GB moves to a later ticket.** Two +devices now measure at 4 GB nominal and both run a full session, so that tier is where hardening +effort pays off. The Gradle-free-provisioning spike below is the 1.9 GB answer and is not being +scoped now - it stays on this page as the evidence for whoever picks that ticket up. + +Primary evidence, with the full runbook and cost tables: +`corpus/results/analysis/c107-lowend-report-2026-07-25.md` in the `CodeOnTheGo-build-benchmark` +repo. + +## What was actually measured + +| Device | RAM | On-device Gradle build | Quick Build | +| --- | --- | --- | --- | +| A56 | 8 GB | works | works - reference device `[measured on a56]` | +| C107 | 3.6 GB | works | works: Ready on 21/30 corpus apps `[measured on c107, earlier pass]` | +| Galaxy A06 | 3.55 GB | works | works: 79 measured edits across 24 corpus apps `[measured on a06]` | +| itel A667L | 1.9 GB | too slow to use - see below | never reached `[measured on itel]` | +| incar Q8 | 1.46 GB | not attempted | not reached `[measured on Q8]` | + +- **The C107's 9 misses are a corpus artifact, not a device limit** - 5 died on + offline-unresolvable KMP coordinates (fixed host-side since) and the other 4 fail on the A56 + too. "21 of 30" describes our corpus, not the C107. +- **The C107 gains more from Quick Build than the A56 does**, not less: across the 19 edits + measured on both, its speedup is higher on all 19 (median 1.77x), saving a median 17.5 s per + edit against 3.1 s on the A56 `[measured on c107, earlier pass]`. The C107 has not been in a pass + since, so this row is historical - no current-pass C107 data exists. +- **The A06 shows the same pattern, and it holds in the current pass.** In the ADFA-4128 benchmark + pass of 2026-08-11 (CoGo build `C-d-0810-2347`), Quick Build beats the standard incremental build + by a median **6.53x** over 79 edits across 24 apps on the A06, against **4.35x** over 76 edits + across 23 apps on the A56 `[measured on a06, a56]`. +- **At the 4 GB tier, CPU decides the experience, not RAM** - but the two 4 GB devices have never + been measured against each other on comparable terms. Each was compared to the A56 instead, and + the two comparisons sit in different eras. Both rows are from earlier passes, superseded as + headline figures by the 2026-08-11 pass and kept because nothing in that pass replaces them - it + carries no C107, and it matches no app across devices: + + | Comparison | Build | Apps matched | Result | + | --- | --- | --- | --- | + | C107 vs A56 | earlier pass, `C-d-0728-1154`, scratch on FUSE | 21 of 21 | C107 **3.5x** slower `[measured on c107, a56; earlier pass]` | + | A06 vs A56 | earlier passes, scratch off FUSE, `C-d-0802-0824` / `C-d-0731-2251` | 7 of 7 | A06 **2.5x** slower `[measured on a06, a56; earlier passes]` | + + For scale in the current pass, unmatched: the A06's median save to live is 2822 ms against the + A56's 1094 ms, and its median standard incremental build 18401 ms against 4662 ms + `[measured on a06, a56]`. Different app sets, so this is not a paired cross-device ratio. + + Chaining those through the A56 puts the C107 at ~1.4x the A06, but the chain crosses the + scratch-off-FUSE change - worth 1.38x on the A56 alone across the same 7 apps + `[measured on a56]` - so treat the A06-vs-C107 gap as `[inferred]`, not measured. What is solid + is the ordering and that both 4 GB devices are usable. The A06's eight Cortex-A55 cores with no + big core are the likely reason it still trails the A56 `[inferred]`. So "4 GB device" is not a + performance class on its own - do not treat one 4 GB measurement as covering the tier. +- **Where the floor actually sits is still unknown.** Both 4 GB-tier devices we own sit within + 50 MB of each other, so they do not narrow the gap: "~3.6 GB works, 1.9 GB does not" remains the + whole of what we know `[measured on a06, c107, itel]`. + +## Why the 1.9 GB device fails + +Not a hard RAM wall, and not a direct lmkd kill of the daemon - it is CoGo's own heap sizing +colliding with the device. CoGo scales the Gradle daemon JVM to device RAM; at 1.9 GB the resulting +heap is small enough that SerialGC thrashes. + +| Gradle daemon on the itel (1.9 GB) | itel | C107 (3.6 GB) | +| --- | --- | --- | +| Heap ceiling CoGo picks | `-Xmx616m`, SerialGC | `-Xmx1304m` | +| Heap in use mid-build | 450-604 MB | - | +| Daemon CPU, almost all GC | 200-293% | - | +| Startup + configuration, trivial project | ~8.8 min `[debloated + screen-off]` | - | +| `hello-java` build | unfinished when we stopped it at 15 min | 82 s | + +All rows `[measured on itel, c107]`. + +- **Retuning the heap cannot fix it**: there is no RAM to grow into, ~300 MB free mid-build + `[inferred]`. +- The Q8 (1.46 GB) never got a build attempt - at idle it already showed 795 MB available, 43 MB + free and ~558 MB in zram, worse than the itel's *failing* mid-build state `[measured on Q8]`. + +### What we actually observed, per attempt + +We never watched a build fail on its own. Every attempt ended at a timeout **we** chose, so the +claim is "too slow to be usable", not "never terminates" - those are different results and only +the first is measured `[measured on itel]`. + +| Run | Condition | Our cap | Build reported started | Outcome | +| --- | --- | --- | --- | --- | +| `20260725T224033Z` | stock | 300 s | no | cut off at the cap | +| `20260726T055750Z` | debloat wave 1, 11 pkgs | 300 s | no | cut off at the cap | +| `20260726T060809Z` | debloat wave 2, 16 pkgs, screen off | 900 s | yes, after ~8.8 min | ~6 min of task execution, still running at the cap | + +- Only the 900 s run says anything about the shape of the failure: startup plus configuration + alone took ~8.8 min, task execution had been running ~6 min more, and nothing in the log + suggested it was stuck rather than crawling. +- **Debloating changed the failure mode, not the outcome** - enough headroom to reach task + execution instead of being cut off before the build started, which is what exposed GC thrash as + the mechanism. +- **Where an uncapped build would land is unmeasured.** Nobody has run one to completion or to a + self-reported failure on this device. + +## The 1.9 GB option, for the later ticket: spike Gradle-free provisioning + +The lever follows from the mechanism: what dies is the big Gradle daemon JVM, and the live reload +loop is a far smaller runtime. If a session could be provisioned *without* an on-device Gradle +build - a baseline prebaked and shipped with the project - the loop might run at 1.9 GB even though +the setup build cannot `[inferred]`. + +- The test is cheap and decisive, which is the main argument for running it: prebake a `hello-java` + baseline, push it to the itel, run one warm edit. It completes or it doesn't. +- Payoff: reopens a device tier the mission targets and CoGo already ships to. +- Cost `[assumed]`: a few days to a first answer; sizing it properly is the spike's own first + deliverable. + +## Still unmeasured + +- **The warm live reload loop at 1.9 GB** - the headline unknown, and what the spike would settle. +- **The Gradle daemon heap CoGo picks on the A06**, and whether `GRADLE_METASPACE_MB` (192 to 384) + is right for a 4 GB device with eight small cores - an open question for Akash `[unmeasured]`. +- Share of the target audience near 2 GB `[unmeasured - needs market-share data]`. +- Anything between 1.9 GB and 3.6 GB; we own no such device. +- `2048` and `ruler` reach Ready but every edit GAPs on a deploy failure on both tiers, while the + standard build of the same edits passes. Closes when reproduced by hand as a real bug and filed, + or traced to the harness and excluded. +- `todo-list` has one scripted edit, and warm comparisons only count edits after an app's first, + so it never yields a warm measurement. Closes when a second edit is added. diff --git a/quickbuild/docs/manual-qa.md b/quickbuild/docs/manual-qa.md new file mode 100644 index 0000000000..684930f388 --- /dev/null +++ b/quickbuild/docs/manual-qa.md @@ -0,0 +1,431 @@ +# Quick Build manual QA + +The manual test pass for Quick Build - one phone, one run through every path. + +Many of these flows already have automated coverage - JVM unit tests, and in a few cases Kaspresso device tests; however, no automated test can check yet for behavior in the running proxy app. Plus automated tests and agent `adb` testing both don't always catch usability issues. + +Cases are organized in the following groups: + +- (A) Core Loop +- (B) Resilience and lifecycle +- (C) Templates and real apps + +## Prerequisite Setup Before Testing + +1. Use a prepared arm device (Samsung A56 is the default) + 1. No lock screen + 2. Stay-awake while charging. + 3. ARM processor (Quick Build is ARM only for now) +2. Flags in the device `Download/` folder: + 1. `CodeOnTheGo.exp` - required. Without it, no lightning button. + 2. `CodeOnTheGo.qbbench` - optional; adds the `bench-events.jsonl` session event log. + 3. Flags are read once per process. After creating or deleting any flag file, force-stop CoGo and reopen it. +3. CoGo asks for the install permission during onboarding. If you skipped it, the first provisioning bounces you to a Settings screen and the session quietly reverts to idle. An automated run that cannot tap that Settings toggle can pre-grant it: `adb shell cmd appops set com.itsaky.androidide REQUEST_INSTALL_PACKAGES allow` + +## Reading the lightning button + +The button is a split button and the session's status display. Every tone has its own icon shape as well as its own colour, so the state reads without relying on colour. There are five. + +| Icon | Tone | Session is | +| ---------------------------------- | ------------ | ------------------------------------------------------------ | +| Solid bolt | READY | No session, or sitting on a successful build | +| Stop square spinning inside a ring | BUILDING | Provisioning, or a build running now. Tapping stops it | +| Hollow bolt | SLOW | The next build cannot take the fast path and will be a full one. Not a failure | +| Sync arrows | RECONNECTING | The compile daemon is being respawned. Transient, resolves itself | +| Bolt with an exclamation mark | ERROR | A failure to act on - a failed build, or a daemon respawn that did not come back | + +Only ERROR is coloured as a failure. A full rebuild and a daemon respawn are ordinary work, so reading either as an error is a bug, not a finding. + +Tap runs Quick Build; the first tap starts the session. Long-press opens a dropdown with three items: Quick Build, Restart session, Help. + +### Setup Project (used for tests T1-T16) + +1. New Project -> **Basic Activity** template, **Kotlin** +2. Set name `mybasic` (applicationId `com.example.mybasic`). +3. Let the initial Gradle sync finish before starting T1. + +The cases below run in sequence and each builds on the last, so run them in order. + +If you need to start over at any point, either run this below, or delete the project in Code on the Go + +```bash +adb uninstall com.example.mybasic +adb shell "rm -rf /storage/emulated/0/CodeOnTheGoProjects/mybasic/build \ + /storage/emulated/0/CodeOnTheGoProjects/mybasic/app/build" +``` + +### Recording the Test + +If you wish to record the test, please use the following command + +```bash +# start recording (screenrecord caps at 30 min and truncates silently, +# so record in segments rather than one long take) +adb shell screenrecord --time-limit 1740 /sdcard/qa-A.mp4 & + +# stop it cleanly from another shell - SIGINT is what finalizes the MP4 +adb shell killall -2 screenrecord + +# pull it off the device +adb pull /sdcard/qa-A.mp4 . && adb shell rm /sdcard/qa-A.mp4 +``` + +Turn on Developer options -> Show taps first, or the taps are invisible in the recording. A file killed any way other than SIGINT has no `moov` atom and will not play; check the pulled file opens before deleting the device copy. + +## Block A - core loop + +### T1 - First tap: provisioning + +Automated coverage: unit + Kaspresso + +Steps: + +1. Open `mybasic` and wait for the Gradle sync to finish. +2. Tap the lightning button once. +3. Approve the OS install prompt when it appears (allow up to 180 s for it). + +Expected: + +1. Build Output narrates each Gradle task as it runs. +2. The install prompt appears. +3. The test app launches, showing "Hello user!" and a floating action button. +4. The lightning button returns to READY (solid bolt). + +### T2 - Code-only edit + +Automated coverage: Kaspresso + +Steps: + +1. Open `app/src/main/java/com/example/mybasic/MainActivity.kt`. +2. In the FAB's click handler, change the message literal to `code: B`. +3. Save. +4. Switch to the test app and tap the FAB. + +Expected: + +1. No install prompt and no dialog. +2. CoGo stays in front - a save never foregrounds the test app. +3. The FAB's message reads `code: B`. +4. The reload takes a few seconds, not a full build. + +### T3 - Compile error, never stale + +Automated coverage: Kaspresso + +Steps: + +1. In `MainActivity.kt`, break the syntax - drop a closing quote, or add a stray `}`. +2. Save. +3. Fix the syntax and change the message literal to `code: B2`. +4. Save, then tap the FAB in the test app. + +Expected: + +1. The status bar reads "Quick Build: BUILD FAILED - see Build Output". +2. Build Output shows the compile error with its file and line. +3. Nothing reloads - the test app keeps running the last good code and still shows `code: B`. +4. The fixing save builds clean and the status clears. +5. The FAB's message moves to `code: B2`. + +### T4 - Resources-only edit + +Automated coverage: Kaspresso + +Steps: + +1. In `app/src/main/res/values/strings.xml`, add `res: A`. +2. Open the layout holding the "Hello user!" TextView (`app/src/main/res/layout/activity_main.xml` in the current template) and point that TextView's `android:text` at `@string/res_label`. +3. Save, and confirm the label on screen reads `res: A`. +4. Change `res_label`'s value to `res: B`. Save. + +Expected: + +1. The label becomes `res: B`. +2. No install prompt. +3. No crash on the resource-table relink. + +### T5 - Assets-only edit + +Automated coverage: Kaspresso + +Steps: + +1. Create `app/src/main/assets/message.txt` containing `asset: A`. +2. Make the FAB's message read from it: `assets.open("message.txt").bufferedReader().use { it.readText() }`. Save. +3. Change `message.txt` to `asset: B`. Save. +4. Tap the FAB. + +Expected: + +1. The FAB's message reads `asset: B`. + +### T6 - Mixed edit, two routes in one save + +Automated coverage: unit + +Steps: + +1. Change res_label in strings.xml to res: C. Do not save yet. +2. Append `+ " (C)"` to the end of the FAB's message expression - after T5 that expression reads from the asset, so it becomes `assets.open("message.txt").bufferedReader().use { it.readText() } + " (C)"`. Do not save yet. +3. Save both at once with Save all files, so the two writes land in one batch. Two separate manual saves will be two builds - the watcher coalesces changes only 150 ms apart, which no one can hit by hand, so that is expected rather than a failure. + +Expected: + +1. One build runs, not two. +2. The label reads `res: C`. +3. The FAB's message ends in `(C)`. + +### T7 - Rebaseline, full-Gradle fallback + +Automated coverage: unit (partial) + +Steps: + +1. Open `app/build.gradle.kts` and make a harmless change - edit a comment. +2. Save, and approve the reinstall dialog. +3. Change the FAB's message literal again. Save. +4. Tap the lightning button once. + +Expected: + +1. The save runs a real Gradle build, visibly longer than T2, and never hot-reloads. +2. CoGo stays in the foreground. +3. Narration reads "a full build is needed", then "rebuilding your app" - never "initial full build". +4. After the reinstall, the code save alone does not redeploy. +5. The one tap relaunches the app with the edit deployed. + +### T7b - A failed rebaseline recovers on save + +Automated coverage: unit (partial) + +Steps: + +1. In `app/build.gradle.kts`, set `compileSdk` to a version the device does not have - 99. Save. +2. Set it back to its original value. Save, and do not tap anything. + +Expected: + +1. The failing build parks the session and the icon shows ERROR. +2. A flashbar names the cause. +3. The fixing save retries by itself, with no tap. +4. The session returns to READY. + +## Block B - resilience and lifecycle + +### T8 - Coalescing of rapid saves + +Automated coverage: unit + +Steps: + +1. Change the FAB's message literal to `A`. Save. +2. Change it to `B` and save, then `C` through `G`, saving each one while the previous build is still running. A warm build takes about a second, so save as fast as you can - saves that arrive mid-build merge into the next one. (At a normal hand cadence you may simply get one build per save; that also passes.) + +Expected: + +1. Fewer builds run than saves. +2. After the last build, the app shows `G`. + +### T9 - Cross-file source dependencies + +Automated coverage: unit + +Steps: + +1. Add `Constants.kt` beside `MainActivity.kt` with `inline fun getLabel(prefix: String) = "$prefix: v1"`. +2. Call it from the FAB's click handler. Tap the lightning button. +3. Change the inline function's body to return `"$prefix: v2"`. Tap the lightning button. +4. Tap the FAB. + +Expected: + +1. The FAB's message shows the new `v2` text - the edited inline function reaches its call site in the running app. + +### T10 - Survives test-app force-kill + +Automated coverage: Kaspresso + +Steps: + +1. Force-stop the test app: `adb shell am force-stop com.example.mybasic`. +2. Change the FAB's message literal. Save. +3. Tap the lightning button. + +Expected: + +1. The save reports "Your app is not running. Tap Quick Build to start it with your changes." +2. The tap relaunches the app with the edit deployed. + +### T11 - Survives CoGo force-kill + +Automated coverage: none + +Steps: + +1. Force-stop CoGo: `adb shell am force-stop com.itsaky.androidide`. +2. Reopen CoGo on `mybasic`. +3. Tap the lightning button. +4. Change the FAB's message literal. Save. + +Expected: + +1. The test app survives CoGo's death (it keeps running while CoGo is gone). The recovery tap then rebuilds and reinstalls the proxy, replacing the process - expected, since each provision bakes a fresh baseline into the APK. +2. The session re-establishes. +3. The edit deploys. + +### T12 - No experiments flag means no Quick Build + +Automated coverage: Kaspresso + +Steps: + +1. Clear the Build Output buffer first - CoGo restores the previous session's output on relaunch, and its lines read exactly like a live run's. +2. Delete the flag: `adb shell rm /storage/emulated/0/Download/CodeOnTheGo.exp`. +3. Force-stop CoGo and reopen it. +4. Open or create a project. +5. When done, restore the flag (`adb shell touch /storage/emulated/0/Download/CodeOnTheGo.exp`) and force-stop CoGo again. + +Expected: + +1. No lightning button. +2. No Quick Build setup build. +3. No "Quick Build:" line in Build Output - just the plain Gradle sync. + +### T13 - One install slot, clobber confirm + +Automated coverage: unit + Kaspresso + +Steps: + +1. With the Quick Build test app installed, press Run. Read the dialog, then confirm it. +2. Tap the lightning button. Read that dialog, then confirm it. + +Expected: + +1. The Run-ward dialog explains it replaces the proxy app with a regular APK. +2. The Quick-Build-ward dialog explains it replaces the regular APK with a proxy app. +3. Both confirm buttons read Replace. +4. Neither switch clobbers without asking. + +### T14 - Stop, restart session, Help + +Automated coverage: Kaspresso + +Steps: + +1. Long-press the lightning button and pick Restart session. +2. Long-press it again and pick Help. + +Expected: + +1. Restart re-provisions cleanly and faster than T1, with no reinstall unless the app's bytes changed. +2. The icon tracks BUILDING, then READY. +3. Help opens a popup describing Quick Build. Note: the content comes from `documentation.db`, a prebuilt asset owned by the documentation repository - until a row for `EDITOR_TOOLBAR_QUICK_BUILD` ships in it, Help opens the "no tooltip" fallback. That reads as a failure here; the fix is a documentation-repo row, not a code change in this repo. +4. The dropdown has exactly three items: Quick Build, Restart session, Help. + +### T15 - Backgrounding, daemon survives + +Automated coverage: none + +Steps: + +1. With the session at READY, press HOME. +2. Wait 2-3 minutes. +3. Return to CoGo, change the FAB's message literal, and save. + +Expected: + +1. The edit reloads at normal T2 speed. Roughly ten seconds or more means the daemon died and cold-started. Read that timing from the raw recording or the logs, never from a shortened video. + +### T16 - Daemon death and respawn + +Automated coverage: unit + +Steps: + +1. Kill the daemon from the Mac: `adb shell "run-as com.itsaky.androidide pkill -f quickbuild-daemon"`. +2. Change the FAB's message literal. Save. + +Expected: + +1. The session goes Degraded and narrates "the compile daemon stopped; restarting it. Your app keeps running." +2. It respawns and re-seeds with no tap from you. +3. The edit then builds and deploys. + +## Block C - templates and real apps (optional tail) + +Each case here needs its own project, so each pays its own cold provisioning cost. + +### T17 - Java template + +Automated coverage: unit + +Steps: + +1. Create a project from a Java template and run T1 on it. +2. Edit the `"Replace with your action"` Toast literal in `MainActivity.java`. Save. +3. Tap the FAB. + +Expected: + +1. The new toast text appears - this exercises the javac path. + +### T18 - Navigation Component, Bottom Navigation + +Automated coverage: unit + +Steps: + +1. Open a Bottom Navigation project, start a session, and launch the app. +2. Tap through all three tabs. +3. Edit the `"This is home Fragment"` literal in `HomeViewModel.kt`. Save. +4. Look at the Home tab. + +Expected: + +1. All three tabs open without crashing. A `Fragment$InstantiationException` is a regression. +2. The edit reloads. + +### T19 - Compose + +Automated coverage: unit (partial) + +Steps: + +1. Open a Compose project and start a session. +2. Edit a `@Composable` function body. Save. + +Expected: + +1. The composable recomposes with the edit and never renders stale. The compile half is unit-tested; whether a composable actually recomposes after a hot swap is device-only. + +### T20 - Real app, Service restart route + +Automated coverage: unit + +Steps: + +1. Open a real app that declares a Service. +2. Edit the Service class. Tap the lightning button. +3. Edit a helper class the Service calls. Tap the lightning button. + +Expected: + +1. The Service edit restarts the app process to take effect. +2. The helper-only edit also restarts. The restart rule keys on what the app declares, not on + what the edit touched: while a Service is declared, every code-bearing deploy restarts + (`DeployPolicy`; see `live-reload-alternatives.md`). + +### T21 - Known-slow path, sora-editor-full + +Automated coverage: none + +Steps: + +1. Wrap and push `sora-editor-full` first - it is not one of the bundled templates. +2. Start a session, make a warm code edit, and save. + +Expected: + +1. The warm edit deploys correctly. This is the one app where Quick Build currently loses to a standard incremental build, so the reload may be slower than a full build. diff --git a/quickbuild/docs/perf-roadmap.md b/quickbuild/docs/perf-roadmap.md new file mode 100644 index 0000000000..093d274096 --- /dev/null +++ b/quickbuild/docs/perf-roadmap.md @@ -0,0 +1,104 @@ +# Performance roadmap + +Where the remaining Quick Build latency lives, and which levers are worth pulling next. Everything here is stage timings, not the headline speedup - that lives in the [README's benchmark table](../README.md), from the ADFA-4128 benchmark pass of 2026-08-11 on CoGo build `C-d-0810-2347`. Lever 1 has shipped; the rest are ranked by ROI. + +| # | Fix | Affects | Payoff per warm edit | Effort | Risk | Status | +| --- | ------------------------------------------------- | ------------------------------ | ---------------------------------------------- | ------ | ---- | ----------- | +| 1 | Move the daemon scratch tree off emulated storage | All apps | -36% subset-median, 6 apps `[measured on a56]` | S | M | **SHIPPED** | +| 3a | Reuse the javac file manager | Apps with Java | ~0.5-1.0 s `[inferred]` | S | L | open | +| 3b | Per-changed-file javac | Apps with Java | ~1.5-3.2 s more `[inferred]` | M | M | open | +| 2 | Incremental dexing | All apps | 2.1-4.6 s | L | M | open | +| 4 | Narrow "Java ABI moved -> recompile all Kotlin" | Mixed Java + Kotlin | 14.9 s, ABI-change edits only | L | M | open | +| 6 | Use more than one core in the compile stages | Resource edits + the slow tail | ~1 s on resource edits; little on the median | M | M | idea | + +Sequencing: + +- Lever 1 had to land first because it masked the win from 2 and 4. +- 3a gates 3b: disjoint risks - stale cache vs stale bytecode - so a bug stays attributable. +- Lever 4 is last on purpose. It is blocked on the same Build Tools API limitation its own KDoc + + documents, and its target should be measured after 1-3 land. +- Lever 5 (stop re-stripping unchanged classes) is not scheduled separately. Lever 1 already took + + it from 4.7-5.5 s to 0.17-0.33 s `[measured on a56]`, and it shares lever 2's cache key, so fold it into lever 2. +- Lever 6 is an idea, not a scheduled lever. Worth trying after 2 and 3 land, or sooner if + + resource-edit latency becomes a complaint. + +Levers 3a/3b are designed in [`incremental-javac-design.md`](incremental-javac-design.md). + +## Lever 6 - parallelism + +Nothing in the Quick Build compile path asks for a core count. The daemon is spawned as a bare `java -jar daemon.jar` with no JVM args and never goes through `GradleBuildTuner`, and d8 is invoked as the single-argument `D8.run(command)` with no `ExecutorService` and no `setThreadCount`. So the parallelism we get is whatever each tool defaults to, and nobody has checked what that is. + +It ranks last because the stage that dominates a median warm edit is the one least able to use a second core: `kotlinc` is 53-60% of it (749 ms A56, 1500 ms A06 `[measured on a56, a06; CoGo build C-d-0809-0940]`), and single-file frontend analysis is largely serial. Two places it plausibly does pay, and they are what to investigate: + +- **aapt2 link on resource edits - 1027 ms on the A56, 2074 ms on the A06**, larger than an entire + + median edit. It fires on only 5 of 184 edits so it never shows in the headline, but it is the dominant cost the moment a user edits a layout, and aapt2 takes a thread-pool size we do not set. +- **The tail, not the median.** Max `kotlinc` is 16.4 s (A56) and 47.1 s (A06) - the multi-file + + compiles that contain real parallel work. + +d8 is a cheap knob and a small one: 11% of a 1.2 s edit caps the win near 65 ms even if halved. + +## Where a warm edit goes + +Reference workload: `sora-editor-full` (288 sources: 214 `.java` + 74 `.kt`, 464 classes / 1.46 MB) - the corpus's worst Quick Build case, and the only app where it lost to a standard build. Warm edit, ms, pre-lever-1 `[measured on a56]`: + +| edit | total | javac | kotlinc | strip | d8 | policy+walks | +| --------------- | ----- | ----- | ------- | ----- | ---- | ------------ | +| Java body | 14718 | 3983 | 659 | 5492 | 3104 | 883 | +| Kotlin body | 14922 | 2849 | 3447 | 4659 | 2421 | 1058 | +| Java ABI change | 28055 | 2677 | 16377 | 4776 | 2268 | 1465 | + +- **javac** compiles all the project's `.java` sources in-process; no incremental mode today. +- **kotlinc** is the Build Tools API incremental compile - just the edited `.kt` files, or every + + Kotlin source if a Java ABI moved. +- **strip** clears `ACC_FINAL` on a mirror of every `.class` so generated proxies can extend user + + classes. It rewrites the whole tree each time, which is why it dominated on FUSE. +- **d8** dexes the whole stripped tree every time. +- **policy+walks** is two `Files.walk` passes plus the ASM header parse that picks restart / + + recreate / rebuild. Not additive with `total` - it mixes a daemon-side and host-side span. + +## Ruled out + +- *"The Java-ABI gate fails open, recompiling all Kotlin on a Java edit."* False - a Java body edit + + measured `nKotlinToCompile=0` `[measured on a56]`. +- *"Non-incremental dexing is the dominant cost."* Half right - dex is 48-60% of the edit, but most + + of that was strip's file I/O, not dex compute. +- *"javac is the bottleneck."* No - 25-36% of a warm edit today (19-27% before the storage move). + + `compileMs` alone reads higher because it excludes dex. +- The tool timings cover only about half a warm edit, so read the analytics event's unaccounted + + **residual** field alongside them. + +## Settled - do not re-derive + +- Daemon IPC is free: 20-60 ms on multi-second calls `[measured on a56]`. +- The "53 s per edit" figure was never a per-edit cost. It was the session's first build, which now + + runs as a background warm compile before the user can save. +- The warm compile is what makes the *first* save fast, and it is worth **6.1x** on that save: + + **1.9 s warmed vs 11.5 s unwarmed**, almost all of it cold `kotlinc`. Matched on/off A/B, 3 trials per arm, one build, `hello-kotlin` (`corpus/results/20260728T153938Z-seed-ab/`) `[measured on a56]`. Tap-to-`Ready` is unchanged, because the warm compile starts after `Ready`. + +## Not covered here + +- **The standard Gradle build's own exposure to the same filesystem toll** - project `build/` dirs + + are on emulated storage too. `[unmeasured]`; tracked separately in Jira. +- **The low device tiers** - every number here is the A56; the C107 and 1.9 GB tier are 4-13x + + slower overall and were not re-measured. +- **`readyou`** - a pure-Kotlin 6-file module measuring 13.7 s / 15.2 s before dropping to 2.9 s + + `[measured on a56]`. No javac, no large class tree; nothing above explains it. + +Evidence: `20260728T172912Z-sora-deepdive/` and `results/analysis/offfuse-comparison-2026-07-31.md` in `CodeOnTheGo-build-benchmark`. diff --git a/quickbuild/docs/pipeline.md b/quickbuild/docs/pipeline.md new file mode 100644 index 0000000000..b859f1d261 --- /dev/null +++ b/quickbuild/docs/pipeline.md @@ -0,0 +1,886 @@ +# The Quick Build pipeline, step by step + +For anyone who knows which step of a Quick Build is misbehaving and needs the file that implements it. The [README](../README.md) has the summary, the terms and the one diagram; this page is the class-level map behind it, in pipeline order. + +Every claim here was checked against the source on this branch. Where a class name, a callback name or a location differs from an older writeup, this page is the one that matches the code. + +## The eight steps + +| # | Step | Module | Open first | +| --- | -------------------------------- | --------------------------------------------------- | ------------------------------------------------------------ | +| 1 | Proxy app build | `:gradle-plugin`, inside the project's Gradle build | [QuickBuildPlugin.kt](../../gradle-plugin/src/main/java/com/itsaky/androidide/gradle/QuickBuildPlugin.kt) | +| 2 | Session control and provisioning | `:quickbuild:core` `service/` + `:app` adapters | [QuickBuildSessionManager.kt](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManager.kt) | +| 3 | Watch and normalize | `:quickbuild:core` `data/` + `domain/` | [AndroidProjectWatcher.kt](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/AndroidProjectWatcher.kt) | +| 4 | Live reload orchestration | `:quickbuild:core` `domain/` + `service/` | [LiveReloadOrchestrator.kt](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestrator.kt) | +| 5 | Compile daemon | `:quickbuild:daemon`, a separate JVM child process | [DaemonService.kt](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/DaemonService.kt) | +| 6 | Deploy and reload | `:quickbuild:runtime`, inside the proxy app | [QuickBuildRuntime.java](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java) | +| 7 | Proxy app rebuild and recovery | reducer recovery states + session control | [ProxyAppBuildRunner.kt](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppBuildRunner.kt) | +| 8 | Observability | `domain/QuickBuildMetricsSink` port + `:app` sinks | [CompositeQuickBuildMetricsSink.kt](../../app/src/main/java/com/itsaky/androidide/quickbuild/CompositeQuickBuildMetricsSink.kt) | + +Steps 1, 2 and 7 run once per baseline. Steps 3-6 are the per-save loop. Step 8 watches all of them. + +## The four processes, and every hop between them + +Quick Build spans four processes, and each boundary is a different transport: Gradle over the tooling API, the compile daemon over line-delimited JSON on stdin/stdout, the proxy app over uid-checked binder AIDL. Arrows prefixed **[cross-process]** leave CoGo; self-messages are work inside CoGo. Steps 1-6 happen once per baseline, the loop repeats per save. + +```mermaid +sequenceDiagram + autonumber + actor Dev as You + participant CoGo as CoGo process + participant Daemon as Compile daemon process + participant App as Proxy app process + participant Gradle as Gradle process + + Note over CoGo,Gradle: Once per baseline - prebuild at project open, then the first tap provisions + CoGo->>Gradle: [cross-process, tooling API] proxy app build (assemble + QuickBuildProxyAppReportTask) + Gradle-->>CoGo: [cross-process] setup.json -> ProxyAppInfo + CoGo->>CoGo: ProxyAppInstaller - install unless the APK sha256 matches the installed one + CoGo->>Daemon: [cross-process, spawn + stdin JSON] configure (classpath, aapt2, d8.jar, android.jar) + Daemon-->>CoGo: [cross-process, stdout JSON] ok + scratchFsType + CoGo->>App: [cross-process, launch intent] ProxyAppLauncher (a tap only, a rebuild stays in the editor) + App->>CoGo: [cross-process, binder] IQuickBuildHost.connect(target, packageName, runningGeneration) + + Note over Dev,Gradle: Per save - the warm loop + Dev->>CoGo: file write (editor save, git pull, Termux script) + CoGo->>CoGo: AndroidProjectWatcher - FileObserver + mtime poll, debounced into one batch + CoGo->>CoGo: WatcherBatchReconciler -> ChangedFiles.Known + CoGo->>CoGo: ChangeClassifier -> BuildRoute + alt live-reload route (CodeOnly, ResourcesOnly, CodeAndResources, AssetsOnly) + CoGo->>Daemon: [cross-process, stdin JSON] compile(allSources, changed, removed) + Daemon->>Daemon: IncrementalCompiler (Kotlin Build Tools API), then JavaCompileStep (javac) + Daemon-->>CoGo: [cross-process, stdout JSON] classesDir + classesChanged + timings + CoGo->>Daemon: [cross-process, stdin JSON] dex(classesDirs) + Daemon->>Daemon: FinalStripper, then DexTool (d8 through its own URLClassLoader) + Daemon-->>CoGo: [cross-process, stdout JSON] classes.dex + opt resources changed + CoGo->>Daemon: [cross-process, stdin JSON] relink(resDirs, manifest, stable ids) + Daemon-->>CoGo: [cross-process, stdout JSON] the relinked resource apk + end + CoGo->>CoGo: GenerationTracker allocates gen N, DeployPolicy picks Recreate or Restart + CoGo->>App: [cross-process, binder + fds] IQuickBuildTarget.onPayload(gen N, dex, resources, assets, metadata) + App->>App: PayloadPersistence write, PayloadStore.apply, ResourceStore.applyTable + App->>App: ActivityTracker.topActivity().recreate() - or exit, for a Restart deploy + App->>CoGo: [cross-process, binder] IQuickBuildHost.reportReloaded(gen N, reloadMillis) + else compile error - no payload is produced + CoGo->>App: [cross-process, binder] IQuickBuildTarget.onBuildStatus(build_failed) + App->>App: StatusOverlay banner, the app keeps running the last good generation + else FullGradleBuild route - the only fallback + CoGo->>Daemon: [cross-process, stdin JSON] shutdown, before Gradle starts + CoGo->>Gradle: [cross-process, tooling API] proxy app rebuild + Gradle-->>CoGo: [cross-process] a fresh baseline, reinstall if the APK changed, then configure again + end +``` + +A generation is allocated only after compile and dex succeed, which is why a compile error burns none. The daemon is shut down before a rebuild deliberately: Gradle's peak and a warm daemon should not share a low-spec device's memory, and the daemon's incremental state is stale after a rebuild anyway. + +### The same loop, at the component level inside CoGo + +The diagram above collapses CoGo into a single box. Here is the same one-save loop as the hops *between* `:quickbuild:core`'s own components - the watcher, the session manager, the classifier, the orchestrator and the deploy channel - tracing the hot-swap happy path with the compile-error and FullGradleBuild branches inline. + +```mermaid +sequenceDiagram + autonumber + actor Dev as User / Editor + participant W as FileWatcher
(data) + participant S as Session mgr
(service/session) + participant C as Classifier
(domain/classify) + participant O as Orchestrator
(domain/reload) + participant D as Compile daemon
(child JVM) + participant Ch as DeployChannel
(service/deploy) + participant App as Proxy app
(runtime) + + Dev->>W: file write (save, git pull, Termux) + Note over W: FileObserver + mtime poll,
debounced into one batch + W->>S: onWatcherBatch(raw batch) + Note over S: WatcherBatchReconciler splits
modified vs removed -> ChangedFiles + S->>O: onFilesChanged(ChangedFiles) + Note over O: union onto pending set,
start only if none in flight + O->>C: classify(pending) + + alt live-reload route (CodeOnly / ResourcesOnly / CodeAndResources / AssetsOnly) + C-->>O: BuildRoute (live-reload) + O->>S: BuildStarted, executor.execute(request) + S->>D: compile(allSources, changed, removed) + Note over D: IncrementalCompiler (Kotlin BTA),
then JavaCompileStep (javac) + D-->>S: classesDir + classesChanged + S->>D: dex(classesDirs) + Note over D: FinalStripper -> DexTool (d8) + D-->>S: classes.dex + opt resources changed + S->>D: relink(resDirs, manifest, --stable-ids) + D-->>S: relinked resource apk + end + Note over S: GenerationTracker.next() -> gen N
(allocated ONLY after compile+dex succeed) + Note over S: DeployPolicy.decide(changedClasses)
-> Recreate (hot swap) + S->>Ch: deploy(gen N, dex, arsc?, assets?, meta) + Note over Ch: subscribe to reports BEFORE the
oneway call, open payloads as read-only fds + Ch->>App: onPayload(gen N, dexFd, arscFd, assetsFd, meta) + Note over App: accept only if strictly newer,
persist -> apply -> recreate top activity + App-->>Ch: reportReloaded(gen N, reloadMillis) + Ch-->>S: DeployResult.Reloaded + S-->>Dev: status: deployed gen N + + else compile error (no payload produced) + C-->>O: BuildRoute (live-reload) + O->>S: executor.execute(...) + S->>D: compile(...) + D-->>S: diagnostics (error) + Note over S: no generation burned,
app keeps last good gen + S->>Ch: notifyBuildStatus(build_failed) + Ch->>App: onBuildStatus(build_failed) + Note over App: StatusOverlay banner + + else FullGradleBuild route (rebaseline) + C-->>O: BuildRoute.FullGradleBuild(reason) + O->>S: InvalidationRequired(reason) + Note over S,App: session owns Gradle - daemon shut down first,
proxy app rebuilt + reinstalled, baseline reset + end +``` + +Two divergences worth holding onto: a **compile error burns no generation** - the app keeps its last good code and shows a status banner - and a **`FullGradleBuild` route rebaselines** via Gradle (daemon down, proxy app rebuilt and reinstalled). A `Restart` deploy (a service/provider/custom-`Application` class was recompiled) is the happy path with one change: the proxy app persists the payload, acks, exits, and the relaunched process boots that generation. + +## Step 1: Proxy app build (`:gradle-plugin`) + +Turns the user's project into an installable proxy app whose manifest names never change, with all user code moved into a swappable payload dex. + +```mermaid +flowchart TB + gradleExt["Gradle + AGP
merged manifest, variant classpath, stable ids"]:::ext + sources["User project
sources, resources, manifest, deps"]:::ext + runtimeAar["quickbuild:runtime AAR
compiled into the proxy app"]:::ext + provisioner["CoGo session control (step 2)
decides when it runs, reads the outputs"]:::ext + + subgraph plugin ["QuickBuildPlugin - generates the proxy app, once per baseline"] + entry["Wire into the variant
(QuickBuildPlugin) also detects Compose"] + manifest["Transform the manifest
(QuickBuildManifestTransformer) rejects android:process"] + proxygen["Generate proxy sources
(ProxySourceGenerator) one proxy per component"] + dextask["Build the payload dex
(QuickBuildPayloadDexTask) bakes the gen-0 baseline"] + json[("setup.json
(QuickBuildProxyAppReportTask) the CoGo handshake")] + end + + gradleExt -- "merged manifest" --> manifest + sources -- "user sources" --> dextask + manifest -- "proxied manifest" --> proxygen + proxygen -- "proxy sources" --> dextask + runtimeAar -- "runtime classes" --> dextask + entry --> manifest + entry --> json + dextask -- "proxy app APK
(libraries + resources + gen-0,
NO loose user classes)" --> provisioner + json -- "setup.json" --> provisioner + + classDef ext stroke-dasharray: 6 4,stroke-width:1.5px +``` + +- Applied by `AndroidIDEGradlePlugin` when the `PROPERTY_QUICK_BUILD_ENABLED` property is set, and only to **debuggable application variants** (`variant.debuggable`, read in `onVariants`). +- Fails the build immediately if `-Pcotg.quickbuild.runtimeAar` is missing or does not point at a file. The runtime AAR is added to the variant's **runtime** configuration only, never the compile classpath. +- Compose is detected in `finalizeDsl`, from either `buildFeatures.compose` or the `org.jetbrains.kotlin.plugin.compose` plugin, and reported to the daemon through `setup.json`. + +Five `DefaultTask` classes, all in one file, [QuickBuildTasks.kt](../../gradle-plugin/src/main/java/com/itsaky/androidide/gradle/quickbuild/QuickBuildTasks.kt): + +| Task | What it produces | +| -------------------------------- | ------------------------------------------------------------ | +| `QuickBuildGenerateSourcesTask` | transforms AGP's `MERGED_MANIFEST` in place, and emits the proxy `.java` sources, the `components.json` asset and `manifest-info.json` from that one input | +| `QuickBuildPayloadTransformTask` | diverts every PROJECT-scope class out of the APK's classes pipeline into `payload-classes/`, handing the pipeline back a jar carrying only the R classes | +| `QuickBuildPayloadDexTask` | javac's the proxy sources, then dexes proxies plus diverted classes into `assets/quickbuild/gen-0.dex` | +| `QuickBuildBaselineGenerationTask` | writes `assets/quickbuild/baseline-generation.txt`, the generation the host allocated for this baseline (`-Pcotg.quickbuild.baselineGeneration`; unset stamps 0). Its own task so the per-provision stamp never invalidates the dex work | +| `QuickBuildProxyAppReportTask` | writes `build/quickbuild//setup.json`, the handshake CoGo reads; wired as `finalizedBy` on `assemble`. Variant-scoped because a flavored project registers one report task per debuggable variant, and CoGo runs `assemble` and reads that variant's report | + +Manifest and proxy-source detail, if the symptom is a wrong or missing proxy: + +- [QuickBuildManifestTransformer](../../gradle-plugin/src/main/java/com/itsaky/androidide/gradle/quickbuild/QuickBuildManifestTransformer.kt) names proxies `Proxy` (`proxySimpleName`), **fails the build** on any `android:process` and on a provider with `android:multiprocess="true"`, neutralizes `android:backupAgent`, and keeps everything else the merged manifest said. +- [ComponentProxiabilityResolver](../../gradle-plugin/src/main/java/com/itsaky/androidide/gradle/quickbuild/ComponentProxiabilityResolver.kt) is the single authority on whether a component gets a proxy: a name list first, then the class file's `ACC_FINAL` flag read off the variant's dependency artifacts. A class it cannot find is assumed project-owned and proxiable. +- [ProxySourceGenerator](../../gradle-plugin/src/main/java/com/itsaky/androidide/gradle/quickbuild/ProxySourceGenerator.kt) emits a subclass, not a delegate. Activity proxies add the `getClassLoader()` override; services, receivers and providers are empty; the Application entry gets no proxy at all. +- The payload dex's min API is `max(variant.minSdk, 30)` (`MIN_PAYLOAD_API`). That is the dex floor, not the device floor - Quick Build runs on API 28+. + +## Step 2: Session control and provisioning (`:quickbuild:core` `service/` + `:app`) + +Owns the session lifecycle. A pure reducer decides transitions; the manager executes their effects. + +[`SessionReducer`](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/SessionReducer.kt) is total - an unhandled (state, event) pair keeps the state and emits no effects - and [`QuickBuildSessionManager`](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManager.kt) runs the effects on one single-threaded dispatcher. Edge labels read `event / effect`; `Ready` and `Deployed` share one reducer branch, so they answer the same events. + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Prebuilding: PrebuildRequested / StartProxyAppPrebuild + Idle --> Provisioning: QuickBuildTapped / StartProvisioning + Prebuilding --> Prebuilding: QuickBuildTapped, sets tapQueued + Prebuilding --> Provisioning: PrebuildFinished with tapQueued / StartProvisioning + Prebuilding --> Idle: PrebuildFinished without a queued tap, or CancelRequested (tap queued only) / CancelProxyAppBuild + Provisioning --> Ready: ProvisioningSucceeded / StartWarmCompile, plus SwitchToProxyApp if a tap asked + Provisioning --> Idle: ProvisioningFailed / SurfaceProvisioningError, sets lastStartFailed + Provisioning --> Idle: CancelRequested / CancelProxyAppBuild + TeardownSession + Provisioning --> Invalidated: ProxyAppRebuildInstallNotConfirmed or ProxyAppRebuildDeferred, parks awaitingRetry + Ready --> Building: BuildStarted + Ready --> Building: WarmCompileStarted, sets warmingCompiler + Deployed --> Building: BuildStarted + Building --> Deployed: BuildSucceeded / SwitchToProxyApp if the tap asked + Building --> Ready: BuildFailed, records lastFailure + Building --> Ready: WarmCompileFinished, or CancelRequested (non-warm build only) / CancelLiveReload + Ready --> Invalidated: InvalidationDetected / RunProxyAppRebuild + Building --> Invalidated: InvalidationDetected / RunProxyAppRebuild + Deployed --> Invalidated: InvalidationDetected / RunProxyAppRebuild + Degraded --> Invalidated: InvalidationDetected / RunProxyAppRebuild + Invalidated --> Invalidated: QuickBuildTapped or HostForegrounded while awaitingRetry / RunProxyAppRebuild + Invalidated --> Provisioning: ProxyAppRebuildStarted, carrying installAutoRetries + Ready --> Degraded: DaemonDied / RespawnDaemon + Building --> Degraded: DaemonDied / RespawnDaemon + Deployed --> Degraded: DaemonDied / RespawnDaemon + Degraded --> Ready: DaemonRespawned + + note right of Ready + A compile error is not a state change: Building goes + back to Ready at the SAME generation with lastFailure + set. That is never-stale in the state machine. + A ProxyAppCrashed from Ready or Deployed lands the + same way; during a non-warm Building it is dropped + (the imminent deploy supersedes the crashed code), and + during a warm compile it is carried as pendingCrash + until WarmCompileFinished surfaces it. + end note + note right of Invalidated + HostForegrounded auto-retries an unconfirmed reinstall + up to MAX_INSTALL_AUTO_RETRIES (2); after that only an + explicit tap retries, and the tap resets the budget. + SessionRestartRequested, from any state but Idle, goes + to Idle / TeardownSession - not drawn, it is universal. + end note + note left of Idle + Idle.lastStartFailed keeps the ERROR tone on the bolt + after a failed start. FileSaved or a teardown clears it + without retrying (a retry stays a tap); a prebuild + carries it through Prebuilding uncleared. + end note +``` + +What each recovery state carries, the nine `InvalidationReason` values and the retry budget: [step 7](#step-7-proxy-app-rebuild-and-recovery-reducer-states--session-control). + +```mermaid +flowchart TB + ui["Editor toolbar
(QuickBuildAction) lightning tap starts/stops"]:::ext + buildExt["Proxy app build (step 1)
proxy app APK + setup.json"]:::ext + pkginst["Android PackageInstaller
may require a user confirm dialog"]:::ext + proxyapp["Proxy app process"]:::ext + daemonExt["quickbuild:daemon process (step 5)"]:::ext + + subgraph mgr ["Session control - turns reducer decisions into real work"] + reducer["Decide state transitions
(SessionReducer) pure; off-ramps Invalidated/Degraded"] + manager["Dispatch reducer effects
(QuickBuildSessionManager) owns the live session"] + factory["Assemble the live session
(LiveSessionFactory) wires executor + orchestrator + watcher"] + prov["Run the proxy app build
(GradleQuickBuildProvisioner) returns ProxyAppInfo"] + scratch["Manage the scratch tree
(QuickBuildScratch) app-private f2fs; free-space guard"] + clobber["Check the installed slot
(QuickBuildClobberCheck) refuses foreign signatures"] + installer["Install the proxy app
(ProxyAppInstaller) fail-fast if no dialog can show"] + launcher["Launch the proxy app
(ProxyAppLauncher) fires the launcher proxy activity"] + conns["Track live AIDL sessions
(ProxyAppConnections) keyed by package + uid"] + daemonCtl["Own the daemon lifecycle
(QuickBuildDaemonController) spawn, epoch, respawn, shrink"] + warm["Fire the warm compile
(LiveReloadOrchestrator) deploys nothing, lowest priority"] + end + + ui -- "start/stop tap" --> manager + manager <-- "events / effects" --> reducer + manager --> prov + prov -- "build invocation" --> buildExt + buildExt -- "APK + setup.json" --> prov + manager --> scratch + prov --> factory + prov --> clobber + clobber --> installer + installer -- "install session" --> pkginst + pkginst -- "SUCCESS / PENDING_USER_ACTION /
ABORTED / FAILURE" --> installer + manager --> launcher + launcher -- "launch intent" --> proxyapp + proxyapp -- "outbound AIDL bind" --> conns + manager --> daemonCtl + daemonCtl -- "configure request" --> daemonExt + manager --> warm + + classDef ext stroke-dasharray: 6 4,stroke-width:1.5px +``` + +- [SessionReducer](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/SessionReducer.kt) is **total** - an unknown (state, event) pair keeps the current state and produces no effects, so a late or duplicate event cannot corrupt a session. +- [QuickBuildSessionManager](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManager.kt) holds the reducer and the live session. Everything stateful runs on one dispatcher, which **must** be single-threaded; effects are `launch`ed rather than run inline so a dispatch never re-enters itself. +- The eight states (`Idle`, `Prebuilding`, `Provisioning`, `Ready`, `Building`, `Deployed`, `Invalidated`, `Degraded`) and the three `SessionFailure` kinds (`CompileError`, `DeployError`, `ProxyAppCrash`) live in [QuickBuildSessionState.kt](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildSessionState.kt), alongside every `SessionEvent`. + +Who starts what: + +| Trigger | Path | +| ------------------------ | ------------------------------------------------------------ | +| project open | `ProjectHandlerActivity` calls `QuickBuildSessionManager.prebuild()` -> `SessionEvent.PrebuildRequested` -> `SessionEffect.StartProxyAppPrebuild` | +| lightning-bolt tap | [QuickBuildAction](../../app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt) -> `QuickBuildTapped` / `CancelRequested` | +| build finished elsewhere | a completed Standard Run build raises `InvalidationDetected(EXTERNAL_FULL_BUILD)`, which refreshes a live baseline (step 7) | + +Provisioning, in the order it happens. [ProxyAppBuildRunner](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppBuildRunner.kt) runs the build -> install -> launch as a stateless verdict; the daemon is torn down before Gradle so the two never share a low-spec device's memory: + +```mermaid +flowchart TB + trig["Provision trigger
first tap, or Invalidated rebuild"]:::ext + gradle["Gradle proxy app build (step 1)
APK + setup.json"]:::ext + pkg["Android PackageInstaller"]:::ext + daemonE["Compile daemon"]:::ext + proxyE["Proxy app process"]:::ext + + subgraph prov["ProxyAppBuildRunner (stateless)"] + disk{"free space ok?
(QuickBuildScratch)"} + down["shut daemon DOWN first
(free memory for Gradle peak)"] + build["provisioner.provision / rebuildProxyApp
-> ProxyAppInfo from setup.json"] + clob["QuickBuildClobberCheck
installed appComponentFactory -> refuse foreign / confirm clobber"] + inst["ProxyAppInstaller.ensureInstalled"] + skip{"installed APK sha256
== candidate?"} + wait["await PackageInstaller broadcast
SUCCESS / PENDING_USER_ACTION / ABORTED / FAILURE"] + sess["beginSession(uid); daemonController.start(config)"] + launch["ProxyAppLauncher: fire launcher proxy activity"] + end + + ok["ProvisionResult.Succeeded
(session assembled, inert)"]:::out + park["InstallNotConfirmed / Failed / Superseded"]:::out + + trig --> disk + disk -- "short" --> park + disk -- "ok" --> down --> build + build --> gradle + gradle --> clob --> inst --> skip + skip -- "match" --> sess + skip -- "differ" --> wait + wait --> pkg + wait -- "confirmed" --> sess + wait -- "unconfirmed" --> park + sess --> daemonE + sess --> launch --> proxyE + launch --> ok + proxyE -. "binds back (connect, step 6)" .-> ok + + classDef ext stroke-dasharray: 6 4,stroke-width:1.5px + classDef out fill:#eef,stroke-width:1.5px +``` + +Step 7's rebuild fallback re-enters the same runner through `rebuildProxyApp`. + +1. [QuickBuildArtifactStager](../../app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt) extracts the runtime AAR and the daemon zip from CoGo's APK assets into `/quickbuild/`. It re-extracts on **every** provision on purpose: a version-keyed marker serves a stale bundle when content changes without a version bump. +2. [GradleQuickBuildProvisioner](../../app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt) runs the proxy app build through CoGo's existing `BuildService.executeTasks`, then parses `setup.json` into [ProxyAppInfo](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt). +3. [QuickBuildScratch](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt) creates `noBackupFilesDir/quickbuild-scratch//{work,out}` and enforces the free-space floor. It must stay on app-private storage - see the README's "Build Scratch Lives in Faster Private Storage". +4. [QuickBuildClobberCheck](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt) plus [RealIdInstall](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/RealIdInstall.kt) read the installed package's `android:appComponentFactory` to decide whether the tap clobbers a Standard Run install. Stateless: an install or uninstall outside CoGo cannot leave it stale. +5. [ProxyAppInstaller](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt) sha256's the built APK against the installed one and **skips the install entirely when the bytes match**. That is what keeps rebuilds free of reinstall prompts. Verdicts arrive as real PackageInstaller broadcasts (`InstallBroadcast`), not uid polling; a failure broadcast with no package name is accepted as ours. +6. [QuickBuildDaemonController](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt) spawns the daemon and owns the epoch rule. `start` and `shutdown` deliberately do **not** bump the epoch; `markIntentionalTransition` does. +7. [LiveSessionFactory](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveSessionFactory.kt) assembles a [LiveSession](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveSession.kt), whose `executor` and `annotationImpact` are switchable delegates so a rebuild can move to a new baseline without discarding the orchestrator's pending set. + +The proxy app binds back to CoGo on launch; [ProxyAppConnections](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/ProxyAppConnections.kt) is the registry both the Android-instantiated host service and the session pipeline meet at. [ProxyAppLauncher](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppLauncher.kt) is only the relaunch primitive - a `fun interface` implemented in `:app` - and a null launcher activity is expected for `` launchers, where the implementation falls back to the default launch intent. + +## Step 3: Watch and normalize (`:quickbuild:core` `data/` + `domain/`) + +Turns raw filesystem events into one deduped changed-file set per save burst. It decides no routes. + +```mermaid +flowchart TB + editor["CodeOnTheGo
editor saves"]:::ext + git["git"]:::ext + termux["Termux process"]:::ext + plugin["Plugin file I/O"]:::ext + fs["Project folder"]:::ext + orch["Build orchestration (step 4)
the only consumer; it owns the classifier"]:::ext + + subgraph watch ["Watch and normalize - one truthful changed-file set per burst"] + watcher["Capture file changes
(ProjectWatcher port, AndroidProjectWatcher impl) inotify + poll"] + filter["Drop never-build files
(WatchFilter) build outputs, temp files"] + coalesce["Debounce, then reconcile
(ChangeCoalescing, then WatcherBatchReconciler)"] + shape["Recognize file shapes
(ChangeClassifier.hasRecognizedShape) reconcile, not route"] + end + + editor -- "saved files" --> fs + git -- "pulled files" --> fs + termux -- "written files" --> fs + plugin -- "updated files" --> fs + fs -- "inotify events + poll" --> watcher + watcher -- "raw file events" --> filter + filter -- "filtered file events" --> coalesce + coalesce <--> shape + coalesce -- "changed-file batch
(no route decided)" --> orch + + classDef ext stroke-dasharray: 6 4,stroke-width:1.5px +``` + +- [ProjectWatcher](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProjectWatcher.kt) is the port; [AndroidProjectWatcher](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/AndroidProjectWatcher.kt) is the only implementation. It is a hybrid by necessity: `FileObserver` for latency, plus an mtime+size poll sweep because the project sits on FUSE, which drops inotify events under load. +- Triggering is on file change from **any** source - the editor, a Termux script, a plugin write, a `git pull` - never on an editor save event. +- Watched files outside the watched roots (the gradle config files and kin) are covered by the poll only; no inotify watch is registered on their parents. +- [WatchFilter](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/watch/WatchFilter.kt) drops build intermediates and recognized rename-tool temp names. An unrecognized temp name (`sed`'s `sedXXXXXX`) survives this filter by design and is dropped later. +- [ChangeCoalescing.kt](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/watch/ChangeCoalescing.kt) holds the `WatchEvent` type and the debounce. Deletions are a separate event kind because a standalone delete fires no create-or-modify event. + +**The reconciler does not run inside the watcher.** [WatcherBatchReconciler](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/watch/WatcherBatchReconciler.kt) is called from `QuickBuildSessionManager.onWatcherBatch`, with `File::isFile` as the existence probe. It re-splits modified against removed: + +- a vanished path with a recognized shape becomes a deletion, +- a vanished path without one is dropped as noise, which is what stops a stray temp file from pushing the whole batch to `FullGradleBuild`, +- a path that still exists but cannot be classified stays modified and keeps its Gradle fallback. + +`ChangedFiles.Known` versus `Unknown` ([ChangedFiles.kt](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/ChangedFiles.kt)) is load-bearing: an empty `Known` set means "nothing changed" and must not recompile, while `Unknown` means "we cannot tell" and makes the next build treat every source as dirty. `plus` reconciles per path with the newer batch winning, so modify-then-delete ends up as a removal. + +**Two unrelated things are called coalescing.** This step's is the watcher debounce (many events, one batch). Step 4's is the orchestrator's pending set (many batches merged while a build is in flight). If a save seems lost, this step is the wrong place to look unless no build was running. + +## Step 4: Live reload orchestration (`:quickbuild:core` `domain/` + `service/`) + +The batch arrives, the classifier picks a route, and the live-reload routes are sequenced and run. This is the never-stale contract: a wrong route means stale code. + +```mermaid +flowchart TB + changesIn["Changed-file batch (step 3)
truthful, deduped, no route decided"]:::ext + sessionExt["Session control (steps 2 / 7)
only it can run Gradle, so FullGradleBuild goes UP"]:::ext + daemonExt["quickbuild:daemon (step 5)
compile / dex / relink over stdio JSON"]:::ext + deployExt["Deploy and reload (step 6)"]:::ext + metricsExt["Observability (step 8)"]:::ext + scratchExt[("Scratch tree (app-private f2fs)
work/ + out/ per project")]:::ext + + subgraph build ["LiveReloadOrchestrator - decides the route, then sequences the build"] + classifier["Classify the change
(ChangeClassifier) the never-stale contract"] + annot["Judge annotation impact
(AnnotationImpact port) kapt/KSP input -> FullGradleBuild"] + orch["Serialize builds; hold pending
one build in flight; results build-id tagged"] + exec["Run the live reload route
(LiveReloadExecutorImpl) route -> daemon ops + deploy"] + client["Talk to the daemon
(DaemonProcessClient) one request in flight"] + assets["Package changed assets
(AssetPackager) staged in the scratch work dir"] + policy["Decide hot swap vs restart
(DeployPolicy) service/provider/Application -> restart,
except the components CoGo injects
"] + deployer["Hand the payload over
(PayloadDeployer) hot swap, restart, retry once"] + gen[("Generation counter
(GenerationTracker + FileGenerationStore) monotone")] + timeline["Stamp host spans
(E2eTimelineRecorder) residual = untimed work"] + end + + changesIn -- "changed-file batch" --> classifier + annot -- "annotation impact" --> classifier + classifier -. "FullGradleBuild route:
needs proxy app rebuild" .-> sessionExt + classifier -- "live reload route + batch" --> orch + orch -- "one batch, in order" --> exec + exec -- "compile / dex / relink ops" --> client + client <--> daemonExt + exec --> assets + assets -- "staged asset zip" --> scratchExt + client -- "changed classes" --> policy + policy -- "hot swap or restart" --> deployer + exec --> gen + deployer -- "payload (gen N+1)
+ metadata (reason, restart)" --> deployExt + orch --> timeline + timeline -- "span timings" --> metricsExt + + classDef ext stroke-dasharray: 6 4,stroke-width:1.5px +``` + +[LiveReloadOrchestrator](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestrator.kt) runs the live-reload path only. A `FullGradleBuild` verdict is escalated as `OrchestratorEvent.InvalidationRequired` and never executed here, because session control owns Gradle, the install prompts and the device's single Gradle slot. What it guarantees: + +- at most one build in flight; new work never cancels a running compile, it waits and coalesces, +- starting a build **moves** the pending set into it; the set clears only on success and a failed batch is unioned back, +- every result carries its build id, so a superseded build's result is discarded, +- after a failure it rebuilds immediately only if new saves arrived mid-build, since an unchanged batch would fail identically, +- event **order** holds only when the public API and the scope share one single-threaded dispatcher. Wire it that way. + +The single-flight scheduler, and the hot-swap-vs-restart decision inside the executed build: + +```mermaid +flowchart TB + ev["onFilesChanged / onLiveReloadRequested /
onWarmCompileRequested"]:::ext + exec["LiveReloadExecutor
runs one build: daemon ops + deploy"]:::ext + + subgraph orch["LiveReloadOrchestrator"] + pend[("pending set + pendingForced
coalesces; never lost")] + gate{"nothing in flight
AND not mid-rebuild?"} + route["classifier.classify(pending)"] + full["FullGradleBuild ->
emit InvalidationRequired (once)"] + start["move pending INTO build;
emit BuildStarted; launch"] + fin["onBuildFinished(buildId)"] + superseded{"buildId still current?"} + tally["recordFailureLocked
2 identical pipeline faults -> rebuild"] + end + + subgraph decide["Deploy decision (inside the executed build)"] + gen["GenerationTracker.next()
persist-before-return; gaps ok, reuse never"] + pol["DeployPolicy.decide(changedClasses)"] + rec["Recreate (hot swap)"]:::out + res["Restart (service/provider/Application
in supertype closure)"]:::out + reb["RebuildProxyApp (pre-v2 baseline)"]:::out + end + + ev --> pend --> gate + gate -- "no" --> pend + gate -- "yes" --> route + route -- "FullGradleBuild" --> full + route -- "live-reload" --> start + start --> exec + exec --> gen --> pol + pol --> rec + pol --> res + pol --> reb + exec --> fin --> superseded + superseded -- "no (baseline reset raced it)" --> pend + superseded -- "yes, failed" --> tally + tally -- "escalate" --> full + + classDef ext stroke-dasharray: 6 4,stroke-width:1.5px + classDef out fill:#eef,stroke-width:1.5px +``` + +A repeated *pipeline* fault (not a compile error) escalates to a proxy-app rebuild once (`ESCALATE_AFTER_IDENTICAL_FAILURES = 2`), then the latch stays spent so it cannot loop. `DeployPolicy` ignores the recompiled set entirely - the payload is the whole class set either way - and restarts whenever the app *declares* a service, provider or custom `Application`, excluding the components CoGo injects (see [live-reload-alternatives.md](live-reload-alternatives.md)). + +Routing. The classifier picks the cheapest still-correct route from the coalesced set by path shape; the first path that demands a full build wins for the whole set: + +```mermaid +flowchart TB + inp["ChangedFiles
coalesced batch (Known or Unknown)"]:::ext + aimpact["AnnotationImpact
KSP/kapt content check (domain/annotations)"]:::ext + + subgraph cls["ChangeClassifier.classify"] + unk{"Unknown?"} + empty{"Known set empty?"} + loop["for each path: kindOf() by shape
gradle-config / manifest / code / resource / asset / unsupported"] + scope{"under fastPathRoots?
(app module scope; empty = disabled)"} + annot{"code changed +
processor input touched?"} + combine["combine code/resource/asset flags"] + end + + full["FullGradleBuild(reason)"]:::out + live["CodeOnly / CodeAndResources /
ResourcesOnly / AssetsOnly / NoOp"]:::out + + inp --> unk + unk -- "yes + processor active" --> full + unk -- "yes, no processor" --> live + unk -- "no" --> empty + empty -- "yes" --> live + empty -- "no" --> loop + loop --> scope + scope -- "no (other module)" --> full + scope -- "yes" --> annot + aimpact --> annot + annot -- "yes" --> full + annot -- "no" --> combine + combine --> live + loop -. "gradle-config / manifest / unsupported
short-circuit" .-> full + + classDef ext stroke-dasharray: 6 4,stroke-width:1.5px + classDef out fill:#eef,stroke-width:1.5px +``` + +- [ChangeClassifier](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/classify/ChangeClassifier.kt) classifies by **path shape**, not content. Recognition is one private `kindOf()` - literal filenames, `src/`+`res/` and `src/`+`assets/` predicates, and the two hardcoded extensions `.kt` and `.java`. It is not an appendable list. +- The same `kindOf()` backs the public `hasRecognizedShape`, which step 3's reconciler uses. Changing one changes deletion-noise semantics too. +- `fastPathRoots` is the app module's scope. A change under any other Gradle module routes to `NON_APP_MODULE_SOURCE_CHANGED`. Empty disables the boundary, which is what single-module projects and pure-shape unit tests use. +- [AnnotationImpact](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/annotations/AnnotationImpact.kt) is the port; the analyzer that implements it lives in the same file. The rule set is split between [AnnotationProcessorProfile](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/annotations/AnnotationProcessorProfile.kt) (which annotations count as processor input, given the processors `setup.json` reported) and [SourceAnnotationScanner](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/annotations/SourceAnnotationScanner.kt) (what the changed file carries). Both are over-inclusive on purpose: anything unparseable returns null, which the analyzer reads as "rebuild". + +Executing: + +| Concern | Class | +| ----------------------------------------------- | ------------------------------------------------------------ | +| route -> daemon ops -> deploy | [LiveReloadExecutorImpl](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImpl.kt) | +| daemon child process and its wire protocol | [DaemonProcessClient](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt) | +| changed assets zipped into the scratch work dir | [AssetPackager](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/AssetPackager.kt) | +| hot swap versus process restart | [DeployPolicy](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/DeployPolicy.kt) | +| everything downstream of that decision | [PayloadDeployer](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt) | +| the monotone counter | [GenerationTracker](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/GenerationTracker.kt) + [FileGenerationStore](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt) | +| span timings and the residual | [E2eTimeline](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimeline.kt) + [E2eTimelineRecorder](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/E2eTimelineRecorder.kt) | +| orchestrator events -> session events | [OrchestratorEventRouter](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouter.kt) | + +**A generation is allocated only after the build steps succeed**, inside `PayloadDeployer`, so a compile error burns none and the proxy app stays where it was. `DaemonProcessClient` holds one request in flight; a process exit without a preceding `shutdown` fails every pending request and fires the death listener, which becomes the `Degraded` flow. + +Adding a file type or a route touches more than the classifier: `WatchFilter` (a file outside the watched roots is never seen), `LiveReloadExecutorImpl` (a new `BuildRoute` is a compile error there, which is the good kind), `AssetPackager` for asset-like types, and `Aapt2Link` for resource-like ones. Assets never reach the daemon. + +## Step 5: Compile daemon (`:quickbuild:daemon`) + +A warm, pure-JVM child process on the bundled JDK. It holds the incremental caches between builds, which is the biggest latency lever. + +```mermaid +flowchart TB + cogo["CoGo DaemonProcessClient (step 4)
the only caller; one request at a time"]:::ext + jdk["Bundled JDK + build-tools
kotlinc via BTA, javac, d8.jar, aapt2"]:::ext + scratchExt[("Scratch out dir (app-private f2fs)
class trees, dex, relinked resource apk")]:::ext + proj["Project sources + variant classpath
from setup.json via configure"]:::ext + + subgraph daemon ["quickbuild:daemon - warm compile, dex and relink"] + router["Route stdio requests
(DaemonMain + RequestRouter) pins protocolVersion"] + tooldisc["Check the toolchain
(DaemonService.configure) never guesses, fails by name"] + ic["Compile Kotlin incrementally
(IncrementalCompiler) BTA with an explicit changed set"] + javac["Compile Java in-process
(JavaCompileStep + JavaSourceAbi) all .java"] + strip["Clear ACC_FINAL on classes
(FinalStripper) so proxies can extend user classes"] + dex["Dex to one classes.dex
(DexTool) d8, min-api 30, no desugaring"] + aapt["Relink the resource apk
(Aapt2Link) full relink with --stable-ids"] + diag["Parse compiler diagnostics
(KotlincDiagnosticsParser) file / line / column"] + stats["Collect per-op statistics
millis + counts, compileOrdinal, scratchFsType"] + end + + cogo -- "requests (line JSON)" --> router + router -- "responses ok/diagnostics" --> cogo + router --> tooldisc + tooldisc -- "tool paths" --> jdk + router --> ic + ic -- "kotlin classes" --> javac + javac -- "merged class tree" --> strip + strip -- "final-cleared classes" --> dex + router --> aapt + ic -- "compiler output" --> diag + diag -- "structured diagnostics" --> router + proj -- "sources + classpath" --> ic + ic -- "class trees" --> scratchExt + dex -- "classes.dex" --> scratchExt + aapt -- "resource apk" --> scratchExt + router --> stats + stats -- "counters merged into the response" --> router + + classDef ext stroke-dasharray: 6 4,stroke-width:1.5px +``` + +- [DaemonMain](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/DaemonMain.kt) owns the serve loop, the stdout/stderr split and the exit contract. Those are wire behavior, so they are stated once in [the protocol reference](../protocol/README.md#transport-rules) rather than here. +- [DaemonService](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/DaemonService.kt) holds the warm state - `configure` builds the session, `compile` / `dex` / `relink` reuse it. [RequestRouter](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/protocol/RequestRouter.kt) is the backstop that turns an escaped exception into an `ok:false` response. +- The wire types live in `:quickbuild:protocol` ([DaemonProtocol.kt](../protocol/src/main/kotlin/org/appdevforall/cotg/quickbuild/protocol/DaemonProtocol.kt)) so client and daemon cannot drift; [ProtocolCodec](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/protocol/ProtocolCodec.kt) is pure string functions. +- The caller supplies every tool path. `configure` requires aapt2, `d8.jar` and `android.jar`, and fails naming each one it was not given - the daemon never discovers or guesses a toolchain, because a guessed `android.jar` compiles against the wrong SDK and only surfaces on device. The authoritative field list is [`DaemonProtocol.kt`](../protocol/src/main/kotlin/org/appdevforall/cotg/quickbuild/protocol/DaemonProtocol.kt) (`ConfigureRequest`); [protocol/README's Requests section](../protocol/README.md#requests) covers the semantics. + +The compile chain: + +- [IncrementalCompiler](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/compile/IncrementalCompiler.kt) drives the Kotlin Build Tools API. Four constraints are invisible from the call sites and each degrades silently: changes must be `SourcesChanges.Known`; the shrunk snapshot path is derived from `setRootProjectDir` and must be exactly `/shrunk-classpath-snapshot.bin`; the first compile must pass all sources as changed to seed the caches; `assureNoClasspathSnapshotsChanges(true)` is only safe once that snapshot exists. +- Java takes two passes - kotlinc reads `.java` for symbol resolution only, then [JavaCompileStep](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/compile/JavaCompileStep.kt) runs javac in-process against the same classpath plus the Kotlin output. javac's pass is not incremental. +- [JavaSourceAbi](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/compile/JavaSourceAbi.kt) decides when a `.java` edit forces a Kotlin recompile, by fingerprinting declarations only. Constant-field initializers and annotations stay in the fingerprint although they look like implementation. Unparseable yields null, which callers must read as "the ABI changed". +- [FinalStripper](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/dex/FinalStripper.kt) clears `ACC_FINAL` on every hot recompile, mirroring the plugin's `ClassOpener`. Kotlin classes are final by default, so this is not an optimization - without it a generated proxy cannot extend its user class and the dex verifier rejects the load. +- [DexTool](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/dex/DexTool.kt) loads the device's `lib/d8.jar` through its own `URLClassLoader` and calls it reflectively, so the daemon carries no AGP or r8 build dependency and works against whatever build-tools the device ships. +- [KotlincDiagnosticsParser](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/compile/KotlincDiagnosticsParser.kt) parses rendered kotlinc text leniently; anything unrecognized degrades to a location-less diagnostic rather than being dropped. javac needs no parsing - its structured diagnostics map onto the protocol shape directly. + +Resources are relinked by [Aapt2Link](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/res/Aapt2Link.kt), which recompiles and relinks everything on every call. Its KDoc is the reference; the three rules that make a partial relink safe are `--stable-ids` (mandatory - without it a missing resource type shifts every later type's id and the manifest's numeric `android:icon` resolves wrong), carrying **both** of AGP's library-resource mechanisms, and passing the fresh project resources as the last `-R` argument. + +### Shrinking the daemon: what works, and what breaks it + +The daemon ships as a ~62 MB zip inside CoGo's APK, almost all of it `kotlin-compiler-embeddable`. Findings from the ADFA-4128 shrink spike, kept here because the build task that implemented them has been removed as an unexecuted follow-up - redo the surgery from this if the size is worth reclaiming, and gate it on a green corpus run. + +- **R8 is ruled out.** `R8 --classfile` shrank the compiler to 37 MB and produced a non-functional daemon: every Kotlin compile died with a `NoClassDefFoundError` initializing a core CLI diagnostics class, because tree-shaking cut a static-init dependency reached only reflectively. A compiler is exactly the kind of code R8 cannot reason about. +- **Dropping whole never-loaded backend subtrees does work**, and is a different operation - plain jar surgery, not tree-shaking. Every class that survives is byte-identical, so nothing that remains can break on a reflective lookup. It removed **~5.7 MB from the compressed daemon zip**. +- The severable entry prefixes, all under `org/jetbrains/kotlin/`: `backend/wasm/`, `ir/backend/js/`, `backend/konan/`, `cli/js/`, `cli/metadata/`, `wasm/`, `js/`, `serialization/js/`, `konan/`, `library/`, `native/`. They are alternative-target codegen backends that a JVM-only incremental compile never loads - [IncrementalCompiler](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/compile/IncrementalCompiler.kt) always drives `K2JVMCompiler`, and Compose is JVM-IR, so it is covered too. +- **Verified severable by the full host corpus matrix** - 13 apps / 48 edits, output-equivalence PASS on all of them, including `compose-kotlin`, `mixed-lang` and the real `sora-editor-lib` slice (`quick-build/corpus/results/`). That matrix is the regression gate for any future attempt. + +## Step 6: Deploy and reload (`:quickbuild:runtime`) + +The Java-only AAR inside the proxy app. It receives payload file descriptors over uid-checked AIDL and makes the new generation the running code. + +```mermaid +flowchart TB + host["CoGo QuickBuildHostService + DeployChannel
payload fds, gated on the proxy app's uid"]:::ext + framework["Android framework
pins Context#getClassLoader to the base APK loader"]:::ext + usercode["User classes + proxies
exist ONLY in the payload dex"]:::ext + + subgraph rt ["quickbuild:runtime - makes gen N+1 the running code"] + runtime["Bind outbound to CoGo
(QuickBuildRuntime) only the proxy app can re-establish it"] + clientRt["Receive deploys
(QuickBuildClient) payload fds + DeployMetadata"] + store[("Payload store
(PayloadStore + Generations) current gen's dex + arsc")] + persist[("Persisted payloads
(PayloadPersistence) fingerprint-keyed to the baseline")] + factory["Instantiate components
(QuickBuildAppComponentFactory) from the current gen"] + loaders["Route by-name class loads
(QuickBuildClassLoaders + LoaderRouter) getClassLoader()"] + res["Swap the resource table
(ResourceSwapStrategy) ResourcesLoader on 30+, shim on 28/29"] + tracker["Recreate live components
(ActivityTracker) the activity stack"] + overlay["Show build status
(StatusOverlay) still on the last good generation"] + end + + host -- "payload fds + metadata,
build status JSON" --> clientRt + runtime -- "outbound bind" --> host + clientRt -- "payload (dex + resource apk)" --> store + clientRt -- "accepted deploy" --> persist + store -- "current gen loader" --> factory + factory --> usercode + store -- "payload loader" --> loaders + clientRt -- "new resource apk" --> res + clientRt -- "hot-swap deploy" --> tracker + clientRt -- "restart deploy:
persist, ack, exit" --> persist + framework -.-> loaders + clientRt -- "build_failed status" --> overlay + + classDef ext stroke-dasharray: 6 4,stroke-width:1.5px +``` + +The lifecycle around that wiring - boot order, then what one deploy decides: + +```mermaid +flowchart TB + subgraph boot["Process start, before any deploy"] + f["QuickBuildAppComponentFactory
declared android:appComponentFactory; the earliest hook an AAR gets"] + r["QuickBuildRuntime.install
ActivityTracker, crash guard - no Context yet"] + b["PayloadStore.ensureBaseline
the baked baseline dex at its stamped generation,
then a strictly newer persisted generation"] + c["first activity: QuickBuildClient.bind + attachPersistence
BIND_AUTO_CREATE, so a CoGo restart reconnects"] + k["IQuickBuildHost.connect(target, packageName, runningGeneration)"] + f --> r --> b --> c --> k + end + subgraph deploy["One deploy - onPayload, on a binder thread"] + p["IQuickBuildTarget.onPayload(gen N, dex?, resource apk?, assets zip?, metadata)"] + g{"Generations.accepts(running, N)?"} + drop["dropped, unreported - acking a refused payload would mislead CoGo"] + per["PayloadPersistence - temp-then-rename, meta.json last"] + rq{"metadata.restart?"} + ex["reportReloaded, then exitForRestart
the fresh process boots the persisted generation"] + ap["PayloadStore.apply - InMemoryDexClassLoader, APK loader as parent"] + rs["ResourceStore.applyTable / applyAssets"] + mainthread["main thread: topActivity().recreate();
no live activity: nothing launched, next launch boots gen N"] + okk["foreground: onActivityResumed -> IQuickBuildHost.reportReloaded
backgrounded: already acked at apply time (recreate defers)"] + bad["failReload -> PayloadStore.restore(previous) + IQuickBuildHost.reportCrash"] + p --> g + g -- "no, not strictly newer" --> drop + g -- "yes" --> per --> rq + rq -- "yes: a service, provider or Application class changed" --> ex + rq -- "no" --> ap --> rs --> mainthread + mainthread -- "rendered" --> okk + mainthread -- "threw" --> bad + end +``` + +A reload that throws restores the previous payload and reports the crash, so the app keeps running the last working code and says so. + + +CoGo's side of the channel. Payload files cross as read-only fds - the kernel dups them, no byte copy - and the verdict returns over the reverse callback; every wait is bounded: + +```mermaid +sequenceDiagram + autonumber + participant Ex as Executor
(PayloadDeployer) + participant Ch as DeployChannel
(DeploySender) + participant Reg as ProxyAppConnections
(registry: target + reports) + participant Host as QuickBuildHostService
(exported binder) + participant App as Proxy app
(IQuickBuildTarget) + + Note over App,Host: at launch: connect(target, pkg, runningGen)
uid gate = expectedUid from PackageManager + App->>Host: connect(...) + Host->>Reg: onConnected(ConnectedTarget) + + Ex->>Ch: deploy(gen N, dex, arsc?, assets?, meta) + Ch->>Reg: target.value (null -> NotConnected) + Note over Ch: subscribe to reports (UNDISPATCHED)
BEFORE the oneway call + Ch->>Ch: open each payload as read-only fd + Ch->>App: onPayload(gen N, dexFd, arscFd, assetsFd, meta) [oneway] + Note over Ch: close our fd ends, proxy keeps its dups + App->>Host: reportReloaded(gen N, reloadMillis) + Host->>Host: enforceCaller(uid) + Host->>Reg: report(TargetReport.Reloaded) + Reg-->>Ch: first { report.generation == N } + Ch-->>Ex: DeployResult.Reloaded(reloadMillis) + + Note over Ch,App: crash -> reportCrash -> DeployResult.Crashed
disconnect / no verdict -> Disconnected / TimedOut(15s) +``` + +- Subscribe-before-oneway (via `CoroutineStart.UNDISPATCHED`) closes the race where a fast report arrives before the collector is listening; reports are matched by `generation`, so a superseded build's report is never mistaken for the current one. +- Payloads are all nullable per the AIDL: dex omitted for a resources/assets-only deploy, arsc omitted when resources didn't move, assets omitted when none changed. + +- [QuickBuildHostService](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/QuickBuildHostService.kt) 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. +- [DeployChannel](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannel.kt) is an interface so the executor stays JVM-testable. +- [BuildStatusJson](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/BuildStatusJson.kt) encodes `onBuildStatus`; the schema, its string-only rule and its defaults are in [the protocol reference](../protocol/README.md#build-status-json-iquickbuildtargetonbuildstatus). +- The AIDL lives with the runtime: `quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/{IQuickBuildHost,IQuickBuildTarget}.aidl`. + +Inside the app: + +| Concern | Class | +| ---------------------------------------------------------- | ------------------------------------------------------------ | +| coordinator; installed at Application instantiation | [QuickBuildRuntime](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java) | +| the bind back to CoGo | [QuickBuildClient](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java) | +| instantiate every component from the current generation | [QuickBuildAppComponentFactory](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory.java) + [LoaderRouter](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LoaderRouter.java) | +| the loader proxy activities return from `getClassLoader()` | [QuickBuildClassLoaders](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoaders.java) | +| current generation + its loader, process-wide | [PayloadStore](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java) | +| newest generation on disk | [PayloadPersistence](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java) | +| resource and asset overrides | [ResourceStore](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java) + [ResourceSwapStrategy](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceSwapStrategy.java) | +| which activity to recreate | [ActivityTracker](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ActivityTracker.java) | +| the error banner | [StatusOverlay](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java) | +| the generation acceptance rule, stated once | [Generations](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java) | + +Behavior worth knowing before changing any of it: + +- `QuickBuildRuntime` is installed by the component factory at Application instantiation, the earliest hook a library gets without a ContentProvider. Context work (binding to CoGo, cache dirs) waits for the first activity, because the Application has no base context yet. +- Failure policy: a reload failure calls `reportCrash` and **rolls back to the old generation**, so the app keeps running the last working code and says so. +- `PayloadStore` loads the dex through `InMemoryDexClassLoader` with the APK loader as parent. Framework and androidx resolve from the APK, user classes exist only in the payload, so parent-first delegation cannot serve a stale user class. At boot it loads the baked baseline dex at the generation stamped beside it (`baseline-generation.txt`; a missing or malformed stamp reads 0, so an old-plugin APK behaves as before), then swaps in a persisted generation only if it is strictly newer than the stamp. +- `PayloadPersistence` writes `payload.dex`, `resources.arsc`, `assets.zip` and `meta.json` temp-then-rename, with `meta.json` last, so a crash mid-persist leaves the store claiming an **older** generation than it serves. That is the safe direction. It discards the store when the stored baseline fingerprint no longer matches the running baseline. +- `BIND_AUTO_CREATE` keeps the binding alive across a CoGo service restart; `onServiceConnected` re-runs connect with the current running generation, which is how a relaunched proxy app catches up. +- Resource swap by API level: 30+ swaps one long-lived `ResourcesLoader`'s provider, loaded with `loadFromApk` because `loadFromTable` does not serve file-based resources; 28/29 take the [LegacyResourceSwap](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java) `addAssetPath` shim; below 28 resource payloads are ignored, which is unreachable in practice. +- Assets have no in-memory API, so the changed-assets zip is merged into one cumulative cache dir (`AssetExtractor.extractCumulative`) and served through a `DirectoryAssetsProvider` on the same `ResourcesLoader` (API 30+), so plain `AssetManager.open()` sees new and modified assets after the recreate. The dir is keyed to the baseline fingerprint and cleared on mismatch - the same trigger that discards a persisted payload - so assets never outlive their baseline. The overlay can add and replace but not hide: a deleted asset stays readable until the next proxy app build. Below API 30 there is no loader to hang the provider on, so the classifier routes any asset-bearing changed set to a full Gradle build rather than deploying assets nothing would read. + +## Step 7: Proxy app rebuild and recovery (reducer states + session control) + +The only fallback. Every state that cannot be trusted ends in a fresh proxy app build. + +```mermaid +flowchart TB + triggers["Invalidation triggers
manifest / gradle / dep / processor-input edit, daemon death"]:::ext + buildExt["Proxy app build (step 1)"]:::ext + pkginst["Android PackageInstaller
reinstall confirm dialog"]:::ext + user["User / lifecycle events
lightning tap, HostForegrounded"]:::ext + proxyapp["Proxy app process"]:::ext + + subgraph rebase ["Proxy app rebuild and recovery - run by session control"] + inval["Park awaiting retry
(Invalidated) saves accumulate; foreground retry MAX 2"] + degraded["Respawn after daemon death
(Degraded) respawn + background warm compile"] + runner["Re-run the proxy app build
(ProxyAppBuildRunner) daemon down for the Gradle peak"] + hash["Decide reinstall vs reuse
reinstall ONLY if the app's bytes changed"] + confirm["Handle the confirm dialog
(ProxyAppInstaller) no showable dialog -> fail fast"] + discard["Reset payloads + scratch
persisted payloads discarded; generation store survives"] + handback["Refresh from Standard Run
a completed Standard Run build refreshes the baseline"] + end + + triggers --> inval + triggers --> degraded + inval -- "retry trigger (tap /
bounded foreground)" --> runner + user --> inval + runner --> buildExt + buildExt -- "new baseline" --> hash + hash -- "bytes changed" --> confirm + confirm <--> pkginst + runner --> discard + confirm -- "confirmed install" --> proxyapp + handback --> runner + degraded -- "respawn + warm compile request" --> runner + + classDef ext stroke-dasharray: 6 4,stroke-width:1.5px +``` + +There is **no rebuild-orchestrator class**. The reducer's `Invalidated` state decides when, and session control executes it: + +1. [ProxyAppBuildRunner.rebuildProxyApp](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppBuildRunner.kt) shuts the daemon down **first**, to free its memory for the Gradle peak - on a 3-4 GB device the two must not coexist. Nothing is lost: the daemon's incremental state is stale after a rebuild anyway, and a survivor would keep serving the old configure's classpath. +2. It runs the provisioner, then probes the `superseded` closure - the manager's epoch check, which the runner never sees directly. +3. `ProxyAppInstaller` decides reinstall versus reuse from the APK's sha256 (step 2), so a rebuild that changed nothing installable shows no dialog. +4. On success it restarts the daemon against the new config. A daemon that refuses the new configuration is its own result, `DaemonRestartFailed`. +5. Then it relaunches the reinstalled app through the same `ProxyAppLauncher` machinery the restart deploy uses - same null-launcher-activity alias handling, same two-attempt swallowed-start retry - and waits for the runtime's reconnect. Best-effort: a relaunch failure never fails the rebuild; it is reported through the rebuild metric's `relaunchOk`/`toRunningMillis` fields (see [debugging.md](debugging.md)'s event table). + +The runner is stateless: it never reads the live session, touches the epoch or dispatches. The manager does all of that with the returned result. + +Handover to and from the orchestrator uses three callbacks - `onProxyAppRebuildStarted`, `onBaselineReset`, `onProxyAppRebuildFailed` (older writeups say `onRebaselineStarted` / `onRebaselineFailed`; those names no longer exist). Only changes that existed when the rebuild started count as absorbed; a save landing mid-rebuild stays pending, and [OrchestratorEventRouter](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouter.kt) re-pends it so it is rebuilt as soon as the baseline resets. + +The two recovery states: + +- **`Invalidated`** carries the `InvalidationReason`, the deployed generation, and an `awaitingRetry` flag with an `installAutoRetries` counter. The nine reasons are every way a baseline stops being trustworthy: `MANIFEST_CHANGED`, `GRADLE_CONFIG_CHANGED`, `UNSUPPORTED_FILE_CHANGED`, `NON_APP_MODULE_SOURCE_CHANGED`, `EXTERNAL_FULL_BUILD`, `ANNOTATION_PROCESSOR_INPUT_CHANGED`, `OUTDATED_BASELINE`, `RELOAD_PIPELINE_FAILED`, `INSTALL_NOT_CONFIRMED` ([BuildRoute.kt](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/classify/BuildRoute.kt)). A rebuild whose reinstall is never confirmed parks here rather than killing the session; `HostForegrounded` retries it up to `SessionReducer.MAX_INSTALL_AUTO_RETRIES` (2), after which only an explicit tap retries, and the tap resets the budget. +- **`Degraded`** is daemon death: respawn plus a background warm compile, while the proxy app keeps running its generation untouched. + +Persisted payloads are **not** cleared by session control. The runtime discards them itself when the stored baseline fingerprint stops matching the running baseline (step 6). The generation counter survives teardown because it lives outside the scratch tree - path and the trap that goes with it in [debugging.md](debugging.md#4-where-quick-builds-files-live-on-device). + +Handback works in both directions: a completed Standard Run build raises `InvalidationDetected(EXTERNAL_FULL_BUILD)` against a live session, which routes into the same rebuild path. + +## Step 8: Observability (`domain/QuickBuildMetricsSink` + `:app` sinks) + +One port, several guarded listeners. Instrumentation must never affect a build, so every call site guards and every sink swallows. + +```mermaid +flowchart TB + pipeline["Quick Build pipeline (steps 2-7)
session, build and timing callbacks"]:::ext + firebase["Firebase Analytics
25-param cap, enforced by a unit test"]:::ext + harness["Benchmark harness (adb)
tails the events file; flag-gated"]:::ext + flags["Flag files in Download/
.exp gates the feature, .qbbench the interface"]:::ext + + subgraph obs ["Observability - measure without perturbing a build"] + port["Accept per-build metrics
(QuickBuildMetricsSink) the domain port"] + composite["Fan out to listeners
(CompositeQuickBuildMetricsSink) each delegate guarded"] + analytics["Ship analytics events
(AnalyticsQuickBuildMetricsSink) timing partition + residual"] + bench["Mirror to the bench log
(BenchQuickBuildMetricsSink + BenchStateRecorder) jsonl"] + benchact["Open project + fire first tap
(QuickBuildBenchActivity) exported, flag-gated"] + end + + pipeline -- "per-build callbacks" --> port + port --> composite + composite --> analytics + analytics -- "capped events" --> firebase + composite --> bench + bench -- "bench-events.jsonl" --> harness + harness -- "BENCH_OPEN_PROJECT intent" --> benchact + flags -.-> bench + flags -.-> benchact + + classDef ext stroke-dasharray: 6 4,stroke-width:1.5px +``` + +| Sink | When it runs | File | +| -------------------- | ------------------------------ | ------------------------------------------------------------ | +| fan-out | always | [CompositeQuickBuildMetricsSink](../../app/src/main/java/com/itsaky/androidide/quickbuild/CompositeQuickBuildMetricsSink.kt) | +| Firebase analytics | always (shipping) | [AnalyticsQuickBuildMetricsSink](../../app/src/main/java/com/itsaky/androidide/analytics/quickbuild/AnalyticsQuickBuildMetricsSink.kt) + [QuickBuildMetrics](../../app/src/main/java/com/itsaky/androidide/analytics/quickbuild/QuickBuildMetrics.kt) | +| benchmark JSON lines | behind the `qbbench` flag file | [BenchQuickBuildMetricsSink](../../app/src/debug/java/com/itsaky/androidide/quickbuild/BenchQuickBuildMetricsSink.kt) + [BenchEventsFile](../../app/src/debug/java/com/itsaky/androidide/quickbuild/BenchEventsFile.kt) | +| session-state mirror | behind the same flag | [BenchStateRecorder](../../app/src/debug/java/com/itsaky/androidide/quickbuild/BenchStateRecorder.kt) | + +- The composite overrides **every** method, including the interface's defaulted ones, so a defaulted event still reaches the delegates that implement it. +- `QuickBuildMetrics.MAX_EVENT_PARAMS = 25` is Firebase's hard cap, pinned by the unit test `the reload-timing bundle stays within Firebase's per-event parameter cap` in `AnalyticsQuickBuildMetricsSinkTest`. What it costs a new stat: [the protocol reference](../protocol/README.md#adding-a-numeric-stat-touches-five-places-and-the-codec-is-not-one-of-them). +- `reload_timeline` is the load-bearing bench event. Its fields and its residual arithmetic are in [debugging.md](debugging.md#reload_timeline-and-why-the-residual-is-the-point). +- [QuickBuildBenchActivity](../../app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchActivity.kt) replaces the human's first tap for unattended runs. It is exported by necessity and double-gated on the experiments and bench flags; it accepts only an existing directory inside `Environment.PROJECTS_DIR`. diff --git a/quickbuild/docs/reliability-gaps.md b/quickbuild/docs/reliability-gaps.md new file mode 100644 index 0000000000..32fbd05f9a --- /dev/null +++ b/quickbuild/docs/reliability-gaps.md @@ -0,0 +1,155 @@ +# Decision: do the open Quick Build recovery gaps block v1? + +**Decision: do #87, #89, #91 and the relink-crash recovery gap block v1? Proposed: no - all go to +v1.1.** Correctness is not at risk in any of them - the never-stale invariant holds throughout. +What is at stake is trust: a live reload path that goes slow, dead, or quiet. The rest of this page +is the evidence for that call, one section per gap - symptom, root cause with file references, +likely fix. + +Device testing (2026-07-25..28) surfaced five user-facing defects. Three are fixed on this branch +(see the last section, which also closes the relink-stuck gap); three are open, alongside the +relink-crash recovery gap. + +| Gap | What the user sees | Frequency | Blocks v1? | +| --- | --- | --- | --- | +| #89 | Red-alert icon; tapping Quick Build does nothing until "Restart session" | No device repro `[inferred]` | TBD | +| #91 | Their own app crash is never surfaced; CoGo blames deploy infra | `[unmeasured]` | TBD | +| #87 | A one-line edit in a Room/KSP project runs a full ~200s rebuild + reinstall | 3/3 when attempted | TBD | +| Relink crash | A reload that crashes the app repeats the crash at every process boot | Trigger fixed; net still absent | TBD | +| Relink stuck | A failed relink re-fails on every later save until a gradle-file touch | `[unmeasured]` | No - fixed below | + +Provenance: `[measured on a56]` = Samsung A56. Untagged prose is code reading against `75483b6eb`. + +## Where they sit in the session lifecycle + +```mermaid +flowchart LR + A[Ready] -->|daemon dies| E[Degraded] + E -->|respawn ok| A + E -->|respawn fails silently| F["Stuck: taps do nothing - #89"] + E -->|annotation-processor project| H["Escalates to full rebuild - #87"] + A -->|proxy app crashes on its own| G["Crash undetected - #91"] + A -->|relink fails twice on the pipeline| V["Escalates to a proxy app rebuild - fixed"] + A -->|relink fails twice on the user's XML| W["Blocks every save - the user is told how to clear it - fixed"] + A -->|reload crashes on recreate| P["Poisoned generation reapplied - relink crash"] +``` + +## #89 - a failed daemon respawn strands the session; taps do nothing + +- **Root cause:** a respawn has two silent exits - superseded before start and mid-start + (`QuickBuildDaemonController.kt:121-133`, log-only) - whose caller arm in `respawnDaemon()` is a + bare no-op (`QuickBuildSessionManager.kt:905-907`), so neither ever dispatches `DaemonRespawned`; + and `reduceDegraded` has no arm for `QuickBuildTapped` (`SessionReducer.kt:374-408`), so the tap falls + into an empty-effects catch-all. `shrinkDaemonForMemory()` contributes: its guard is on `Building` + only, so in `Degraded` it bumps `daemonEpoch`, which is what makes an in-flight respawn discard + itself. +- **Evidence:** code reading only, no device repro. The swallowed tap is not race-dependent: any + time the session is Degraded, taps do nothing. +- **Likely fix:** give `Degraded` a `QuickBuildTapped` arm re-issuing `RespawnDaemon`, guarded + against stacking respawns. Alternatives: explanatory text only; a mutex serializing the daemon + lifecycle (bigger, addresses the cause). + +## #91 - an organic proxy-app crash never reaches the crash surface + +- **Root cause:** the runtime only reports a crash while a reload is in flight + (`QuickBuildRuntime.java:306` gates on `pendingReloadGeneration >= 0`, which is -1 between + builds). The disconnect *is* detected - `ProxyAppConnections.onDisconnected()` emits + `TargetReport.Disconnected` - but the session manager's collector only tests for `Crashed` + (`QuickBuildSessionManager.kt:281`). Today's entire user-visible consequence of a crash is an + icon color change. +- **Evidence:** code reading; no run deliberately crashed a proxy app between builds `[unmeasured]`. +- **Likely fix:** route `Disconnected` to the session as `TargetDisconnected` - small, fixes the + lie, recovers no stack. Reporting crashes unconditionally with a sentinel generation gets the + real summary but the reducer must not treat an organic crash as a reload failure. + +## #87 - a body-only edit escalates to a full proxy app rebuild + +- **Symptom:** a one-line method-body edit in a Room/KSP project triggers a full Gradle rebuild + + reinstall dialog - 198s in the captured run `[measured on a56, 2026-07-28]` instead of a ~2s + reload. Invisible trigger: the compile daemon died between builds, usually because the OS + reclaimed memory while the user looked at their app. +- **Root cause:** on respawn, `LiveReloadOrchestrator.kt:283` re-primes the pending set with + `ChangedFiles.Unknown`, an absorbing element that destroys the known file set, and + `ChangeClassifier.kt:47` then escalates unconditionally on `annotationImpact.active` ("project + has any processor", not "this edit touched processor input"). Unnecessary: the changed-file set, + the annotation baseline and on-disk sources are all still intact at that point. +- **Evidence** `[measured on a56, 2026-07-28]`: 3/3 reproductions, + `corpus/results/20260728T113213Z-task32-roomksp-online/DEVICE-FINDINGS.md:46-57`. +- **Likely fix:** reclassify from the preserved set instead of falling back to `Unknown` - must + still distinguish "lost the daemon" from "lost track of files", since a genuinely unenumerable + change has to escalate. + +## Relink crash - a crashing reload has no self-healing + +- **Symptom:** a reload crashes the proxy app on `recreate()`, and every later process boot + re-reads the same payload and repeats it until the session is reset. +- **Root cause:** `handlePayload` (`QuickBuildRuntime.java`) persists the payload before applying + it, and the crash lands on a later main-thread frame - caught only by the process's + uncaught-exception handler, so `failReload`'s rollback never runs. +- **Status:** the known trigger (resource-id drift on relink) is closed by `aapt2 link + --stable-ids`, device-verified 2026-07-28 `[measured on a56]`. What's missing is the + trigger-independent net: treat a crash during a pending reload as reason to distrust the + just-applied generation and fall back to the last known-good one. + +## Fixed on this branch + +- **Relink stuck** - a failed relink re-failed on every later save forever, because the + never-lose-an-edit invariant re-queues the failed batch and nothing ever retried differently. Two + causes, each with its own fix, because they need opposite treatment. + + **Pipeline half - the daemon could not link at all** (`BuildOutcome.InfrastructureFailure`), for a + reason no edit could reach. Fixed in `LiveReloadOrchestrator`: two consecutive builds failing with + an *identical* non-daemon-death `InfrastructureFailure` emit + `InvalidationRequired(RELOAD_PIPELINE_FAILED)`, so the pending set is handed to a proxy app + rebuild - the same visible Gradle fallback a gradle-file touch produces, without the user having + to know that trick. `recordFailureLocked` carries the reasoning for what is excluded: compile + errors (the user's code, already on screen), daemon deaths (their own respawn path), and warm + compiles (never surfaced). **Loop guard:** the escalation is latched to once per baseline - + cleared by a success or by `onBaselineReset`, deliberately NOT by `onProxyAppRebuildFailed`, so a + rebuild that fails leaves plain build failures instead of rebuilding on every save. + + **aapt2 half - aapt2 rejects the project's resources** (`DaemonService.relink` -> + `DaemonResponse.failure(id, diagnostics)` -> `BuildOutcome.CompileError`). The mechanism is not + the dirty delta: `LiveReloadExecutorImpl.relink` links the **whole `res/` tree from disk**, not + the changed set, so an unlinkable resource fails every later build whatever the user saves - a + pure-code save included, which is why the error looks unrelated to what they just did. Almost + always the user's own error, and their next good save clears it. What has no self-healing is a + reference the relink cannot resolve at all - a library resource absent from the proxy app build's + resource snapshot - which no edit to the file naming it fixes. Fixed by **telling the user**: a + repeating aapt2 rejection now sets `OrchestratorEvent.BuildFailed.relinkStuck`, which the session + manager surfaces once per streak as `QuickBuildNotice.RELINK_STUCK` - asking for the fix first and + naming Restart session (long-press Quick Build), whose fresh proxy app build resolves against the + full resource set. `blocksEveryBuild` attributes the failure to aapt2 rather than kotlinc by + requiring every error to name a file under `res/`, which is exact because a failed compile returns + before the relink runs, so the two never mix in one outcome. Latch cleared by a success or a fresh + baseline. + + **Why the aapt2 half is deliberately NOT auto-escalated** (unchanged judgement, restated because + the fix chose around it): the identical-repeat signal cannot tell a **fixable** user typo from an + **unfixable** reference - both come back as aapt2 diagnostics naming a file under `res/`. So + escalating would fire on the ordinary flow "resource is broken, user saves a Kotlin file next", + spending ~200s of Gradle on a typo that the next save would have cleared in ~2s; and a proxy app + rebuild that fails dispatches `ProvisioningFailed`, which drops the whole session to `Idle` + (`SessionReducer.kt:133-138`). Trading a visible, self-clearing compile error for a killed session + is a worse defect than the one being fixed. A notice needs no such discrimination, because the + advice is correct in both cases. + + **Never-stale holds throughout:** the user sees aapt2's diagnostics on every attempt and nothing + is deployed; the notice adds a message and changes no build or deploy decision. The gradle-file + touch still works as before, and `PayloadStore` still drops any persisted store whose fingerprint + no longer matches the new baseline dex. + + **Not device-verified** - the A56 was unplugged for both changes `[unverified on device]`. Covered + by 7 orchestrator unit tests for the pipeline half (3 watched red before the fix) plus 3 + orchestrator tests and 1 session-manager test for the aapt2 half, all 4 watched red under mutation + before the fix `[measured on host]`. What a device walk still owes: the toast actually appearing, + and Restart session actually clearing an unfixable-reference case end to end. + +- **#88** - every deploy after a proxy app rebuild reinstall failed "Proxy app is not connected" + until the user relaunched their app. Fixed by a deploy-time launch-and-retry-once + (`PayloadDeployer.deployRecovering`); its KDoc carries the rationale. Also removes most of #91's + symptom. +- **#90** - a backgrounded reinstall waited 180s in silence, then showed a message wrong about what + happened. Fixed by a fail-fast park with a truthful message and a bounded re-prompt + (`ProxyAppInstaller.kt`, `SessionReducer.kt`). Not verified on device: the dialog reappearing on + foregrounding, a user actually declining, and the initial-provisioning timeout. diff --git a/quickbuild/docs/resource-updates.md b/quickbuild/docs/resource-updates.md new file mode 100644 index 0000000000..940bc0ce9e --- /dev/null +++ b/quickbuild/docs/resource-updates.md @@ -0,0 +1,45 @@ +# How resource updates are handled + +What happens when the user saves a resource file while Quick Build is running, who consumes each result, and the design decisions around it. Terms (proxy app, payload, rebaseline, orchestrator) are defined in the [README](../README.md); the reload steps are in [pipeline.md](pipeline.md). Behavior verified on-device 2026-08-13 UTC (A56, CoGo C-d-0812-1737). + +## Two independent pipelines fire on a resource save + +Saving a resource file (a values file, a layout, a drawable) starts two pipelines that never wait on each other: + +1. **Quick Build's reload pipeline.** The project watcher picks up the write (inotify plus a + + 2 s mtime poll), the coalescer batches it (150 ms quiet window), and the daemon relinks resources with aapt2 using `--stable-ids`, packages a payload, and deploys it to the running proxy app. New files, new values, and new resource ids all reach the running app this way in seconds. No Gradle. +2. **The IDE's editor-freshness build.** The editor's save path sets `resourceXmlSaved` + + (`SaveResultFlags.kt`) and calls `ProjectManagerImpl.generateSources()` from two call sites (`SaveFileAction`, `EditorHandlerActivity`), which runs a Gradle build of the source-generation tasks for each Android module's selected variant - resource generation, source generation, resource processing, plus the viewBinding base-classes task when viewBinding is enabled. 5 to 16 s on an A56-class device. Its job is regenerating the intermediates R.jar the language servers read. The path predates Quick Build (`3f7db1771`, 2022-05-03 - Gradle was the only way to surface a new `R.string.foo` to the editor at the time). Previously it fired for any XML save; it now fires only for resource files (see Design decisions). + +Quick Build never consumes the Gradle build's output. The cost of pipeline 2 is contention: CPU against the reload, and the single Gradle slot against the user's own sync or run. + +## Who consumes what + +| Consumer | Where its R symbols come from | Needs the Gradle build? | +| ------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | +| The running app | Quick Build's aapt2 relink. A new id declared and referenced in XML resolves inside the same relink that allocates it (verified in manual QA: new `res_label` string referenced from a layout - see manual-qa.md) | No | +| Java language server | The intermediates R.jar. A successful `generateSources` posts the same `ProjectInitializedEvent` a sync posts, and `JavaLanguageServer.setupWithProject` clears its R.jar cache on it (verified: a new id resolves in a `.java` file with no sync) | Yes | +| Kotlin language server | Nothing after first init. The sync-update listener body is `= Unit` and no code path re-reads a regenerated R.jar; only an IDE restart refreshes it (verified: a new id stays flagged before and after a sync) | No - the build has no effect. Pre-existing CoGo bug, ticketed separately | +| Quick Build's hot compile | A payload R.jar copied at provisioning. A new resource id referenced from code mid-session may not compile until a rebaseline | Unverified - open item below | + +## Design decisions + +**Keep the Gradle build, but only for resource files.** The build is what keeps the Java editor's `R.*` resolution current, so it cannot be removed. The trigger is `resourceXmlSaved` (`SaveResultFlags.kt`), which narrows the old any-XML condition using `ProjectManagerImpl.isAndroidResource`: a prefix match of the file path against the Gradle model's actual resource directories (`sourceProvider.resDirs`, plus dependent modules'), so custom `res.srcDirs` are covered - it does not match on a folder named `res`. + +**Defer the build while a Quick Build session is live.** Implemented on this branch: `GenerateSourcesDeferral` (app module), attached to the session-state flow. Since Quick Build never reads the build's output, running it after the reload finishes only delays editor symbol freshness by a few seconds and removes the CPU contention. The mechanism is a coalescing queue, not a save-time status check: at save time the Quick Build pipeline has not started yet (the watcher batch is still inside its 150 ms debounce), so sampling "is Quick Build building?" at that moment misses the primary case. Instead: while a session is active, park the request and run one coalesced `generateSources()` when the orchestrator goes idle; with no session, run immediately as today. This also fixes a silent drop - `generateSources` bails when a build is already running (`ProjectManagerImpl.kt`, the `isBuildInProgress` early return), which swallowed 12 of 18 requests in the manual QA pass (manual-qa.md). Parking alone does not fix it, because session state cannot see a Gradle build the session did not start: a project sync or the user's own Run holds the same single slot while the session reads as settled, so the release fires into a refusal. `generateSources` therefore reports whether it dispatched, and a refused request stays parked and retries on the same grace window rather than being cleared - bounded, so a durable refusal (no build service, tooling server down) gives up instead of burning timers. + +**Not chosen: skipping the build when no symbol changed.** Diffing the saved file's declared symbol set (names in values files, `@+id` in layouts; other files' symbol is the filename) costs single-digit milliseconds, but needs a per-file symbol cache with seeding and delete/rename handling. With the deferral in place the build no longer competes with the reload, so this is a followup, not a requirement. + +## Open items + +- **Probe: does a new resource referenced from code compile mid-session?** The hot compile + + resolves `R.*` from the payload R.jar snapshotted at provisioning, which suggests it fails until rebaseline - but this has not been observed. Manual QA only covered the XML-reference case, which works. +- **Kotlin's frozen jar view.** Separate CoGo bug, independent of Quick Build; no Gradle run + + helps until the Kotlin LSP re-reads jars. Ticket drafted with repro. +- **Alternative: Quick Build emits its own R at aapt2 link time.** Would let code reference a + + new resource mid-session without Gradle. Feasible per AGP 8.8.2 source (AGP generates R bytecode with ASM from aapt2's text symbol table; ids are inlined constants, so `--emit-ids` fed forward as the next `--stable-ids` is mandatory), but the daemon's `IncrementalCompiler` snapshots its classpath once per session, so a fresh R jar means a compiler rebuild (~2.7 s full app-module recompile on mybasic/A56) per new-resource edit. Does not help the editor either way - the language servers read the Gradle-owned R.jar. Gated on the probe above plus a rebuild-cost measurement on target hardware. diff --git a/quickbuild/docs/why-not-android-jar.md b/quickbuild/docs/why-not-android-jar.md new file mode 100644 index 0000000000..f345580676 --- /dev/null +++ b/quickbuild/docs/why-not-android-jar.md @@ -0,0 +1,92 @@ +# Information: why Quick Build does not replace `android.jar` + +David proposed two routes to a fast on-device edit loop. The first, **Stubby**, ran the app's +`.class` bytecode directly in a JVM inside a stub app and replaced `android.jar` with an +interceptable copy - a design he wrote up in "Improving User Productivity" (Confluence +`591495169`). The second is what became ADFA-4128, and his own 25-May-2026 note on that same page +points at it as "a less technically-challenging idea". This page records why the cheaper of his two +ideas won, and - because he asked the `android.jar` question again on the discussion doc +(`900038699`, threads `906395719`, `907378691`, `905150475`) - why the hook could not be +`android.jar` even in the parts of Stubby we wanted to keep. + +## The two properties worth protecting + +David's reason for reaching for `android.jar` was sound, and both properties survive in the +shipped design: + +- **User code untouched.** No base-class edits, no annotations, no build-file surgery in the + user's project. Quick Build interposes at the manifest, not in the user's sources + ([`component-proxying-design.md`](component-proxying-design.md)). +- **No per-build cost.** Interception is wired **once, at proxy-app setup time**, not on each + save. The hot loop never rewrites bytecode to install a hook. + +## Why `android.jar` cannot be the hook + +Three properties, any one of which is fatal: + +1. **It is a compile-time stub.** Every method body in `android.jar` throws + `RuntimeException("Stub!")`; the jar exists to satisfy `javac`/`kotlinc` and is never + executed `[inferred]` (checkable by decompiling any SDK `android.jar`). It is not even a + complete view of the platform: hidden members are omitted outright, which is why the + API 28/29 resource shim has to reach `AssetManager.addAssetPath` reflectively - on the JVM + that call cannot be made at all, and a unit test pins the resulting `IOException` + (`runtime/src/test/java/.../LegacyResourceSwapAddAssetPathTest.java`) `[measured on host]`. +2. **It has no runtime existence to modify.** At runtime the framework comes from the device's + boot image / `framework.jar` on the boot classpath. Shipping a modified `android.jar` in the + APK changes nothing, because nothing loads it `[inferred]`. +3. **ART's boot classpath cannot be shadowed.** Classloading is parent-first and the boot + classpath is the root parent, so an app-dex class named `android.content.res.Resources` + loses to the platform's `[inferred]`. + +## What replacing it would actually cost + +Akash's point on thread `905871361` is the load-bearing one: **ART executes dex only, never +`.class`.** So Stubby is not "swap a jar" - it is (a) porting or shipping a JVM to Android to run +the `.class` files, and (b) building an `android.jar` marshalling bridge that vectors every +framework call from that JVM across to the real platform. David's own IPC-boundary analysis in +that thread priced the bridge and concluded it was "not trivial work but doable" - which is the +same verdict the spike reached, from the other end: the Mini-Stubby `DESIGN.md` decision D1 +rejects the dex-free JVM as "too technically challenging", specifically because it "must intercept +**all** of `android.jar`" (branch `ADFA-4128-prototype`, `spike/mini-stubby/DESIGN.md`). + +And the thing that buys is only the dex step: + +- In the spike, whole-app `d8` was ~0.3 s against kotlinc's ~2.3 s `[measured on a56]` + (`DESIGN.md` D1) - a ~0.3 s saving for a JVM port. +- In shipped Quick Build the dex step is larger, 2.2-3.1 s of a ~15 s warm edit on the corpus's + worst app `[measured on a56]` ([`perf-roadmap.md`](perf-roadmap.md)) - but the cheap fix for + that is **incremental dexing** (lever 2, 2.1-4.6 s per warm edit `[measured on a56]`), not a + new runtime. +- Either way **compile remains the dominant term**, and Stubby does not help it: it still has to + run the same `kotlinc` and `javac` `[inferred]`. + +## What the shipped design does instead + +- **Resources** go through `ResourcesLoader`/`ResourcesProvider.loadFromApk` on API 30+, with an + `addAssetPath` shim on 28/29 (`runtime/.../ResourceStore.java`, `ResourceSwapStrategy`). This is + the platform-sanctioned redirect for exactly what thread `905150475` asked for - replacing + resource loading - and it needs no framework patching. API 28/29 is unit-tested, device + verification still pending `[unverified]`. +- **Components** go through generated `Proxy` classes named in the merged manifest, so + the OS instantiates a class that is in the APK while the code behind the name changes every + reload (thread `906166273`). Generated at setup, so per-build cost stays zero. +- **Swapping the user's base classes** was the other option in that thread. It works where we own + the code (CoGo templates) but not for arbitrary apps - `sora-editor` and `StreetComplete` extend + androidx classes - and it violates "user code untouched" `[inferred]`. +- **The boundary that actually matters is the manifest, not `android.jar`.** From the spike's + `CAPABILITY-MATRIX.md`: anything the OS reads from the manifest *before your code runs* + (activities, permissions, icon/label, exported components, custom `Application`) belongs to the + installed shell; everything the payload's code touches at runtime - views, resources, themes, + native libs, Compose, Fragments - is hot-loadable. Quick Build draws its line there. + +## What would reopen the question + +- Compile time falling far enough that dex becomes the dominant cost, *after* incremental dexing + lands. Nothing in today's numbers points that way `[measured on a56]`. +- Devices below API 28, where neither resource path exists; the candidate there is Akash's + `Context.getResources()` hook (thread `905609219`), not `android.jar` `[unmeasured]`. + +Thread `905674797` is the useful summary of the delta: David's own description of "Son of Stubby" +matches what got built, with two substitutions - deploy is binder + `ParcelFileDescriptor` rather +than reading the project directory, and interception is setup-time proxies rather than +`android.jar`. From 0df5089f8c694ddfcea58457dcc17b49d838571a Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 22:39:40 -0700 Subject: [PATCH 2/4] =?UTF-8?q?ADFA-4128:=20qb=2001=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20doc-vs-code=20corrections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README ADR reference: 0012 -> 0015 (the ADR this branch actually adds) - pipeline.md task table: dropped nonexistent components.json asset; task emits proxy sources + manifest-info.json intermediate (not shipped in the APK) - debugging.md: dropped nonexistent assets/quickbuild/components.json; APK carries gen-0.dex + baseline-generation.txt, component names flow via manifest-info.json/setup.json intermediates - README test trap: ignoreFailures is analysis-run-only (sonar/sonarqube/jacocoAggregateReport), ordinary test runs gate on failures Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W --- quickbuild/README.md | 4 ++-- quickbuild/docs/debugging.md | 5 +++-- quickbuild/docs/pipeline.md | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/quickbuild/README.md b/quickbuild/README.md index e17dc23525..856f4b3ffb 100644 --- a/quickbuild/README.md +++ b/quickbuild/README.md @@ -259,7 +259,7 @@ Synthetic apps ship with their oracles and results in the `CodeOnTheGo-build-ben ### The Per-Save Path Does Not Use Gradle -Provisioning runs a real Gradle build, but every save after it does not - the daemon compiles, dexes and swaps resources directly, because a Gradle invocation per save costs seconds that this feature exists to remove. ADR 0002 chose the Gradle Tooling API for on-device builds and still reads as covering all of them, so this branch adds ADR 0012 to record the second path and its limits rather than leave 0002 quietly overstated. Cost: two build paths to keep honest. The proxy app is only ever produced by AGP, and the per-save path is never allowed to produce an installable artifact. +Provisioning runs a real Gradle build, but every save after it does not - the daemon compiles, dexes and swaps resources directly, because a Gradle invocation per save costs seconds that this feature exists to remove. ADR 0002 chose the Gradle Tooling API for on-device builds and still reads as covering all of them, so this branch adds ADR 0015 to record the second path and its limits rather than leave 0002 quietly overstated. Cost: two build paths to keep honest. The proxy app is only ever produced by AGP, and the per-save path is never allowed to produce an installable artifact. ### The Proxy App Connection Registry Is a Process-Wide Singleton @@ -276,7 +276,7 @@ Unit and Kaspresso tests cover CoGo itself; anything that crosses into the proxy - **Script a session over `adb`.** Under the `CodeOnTheGo.qbbench` flag an exported activity opens a project and fires the first tap in place of a human, so a whole session - including a retry after an install-confirm timeout - runs unattended. Command and options: [`docs/debugging.md` §6](docs/debugging.md). - **Run the corpus.** The `CodeOnTheGo-build-benchmark` repo carries the open-source app corpus, realistic edits and the E2E harness. Correctness comes from its two oracles - recompiled-class bounds and output equivalence - not from timings. Commit the results dir for any compile-pipeline change, and cite one for any latency claim. -A new edit class or route needs all three: a classifier test, a corpus edit declaring `expected.route`, and an on-device walk if it deploys. Two traps: the root build sets `ignoreFailures = true` on test tasks, so read `/build/test-results/` rather than trusting `BUILD SUCCESSFUL`; and nothing runs the real daemon jar against the real client ([`DaemonProcessClientTest`](core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientTest.kt) drives a scripted fake), so a protocol regression that compiles only surfaces on device. +A new edit class or route needs all three: a classifier test, a corpus edit declaring `expected.route`, and an on-device walk if it deploys. Two traps: the root build sets `ignoreFailures = true` on test tasks only for analysis invocations (`sonar`, `sonarqube`, `jacocoAggregateReport`) - an ordinary test run gates on failures, but on an analysis run read `/build/test-results/` rather than trusting `BUILD SUCCESSFUL`; and nothing runs the real daemon jar against the real client ([`DaemonProcessClientTest`](core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientTest.kt) drives a scripted fake), so a protocol regression that compiles only surfaces on device. ### How to Run On Device diff --git a/quickbuild/docs/debugging.md b/quickbuild/docs/debugging.md index 6edc5f4dd2..5956128e8e 100644 --- a/quickbuild/docs/debugging.md +++ b/quickbuild/docs/debugging.md @@ -204,8 +204,9 @@ Four traps in that table: - **`resources.arsc` is not a resource table.** It holds the whole relinked resource apk; the filename is historical ([`PayloadPersistence.java`](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java)). -- **The baseline is inside the APK, not on disk** - `assets/quickbuild/gen-0.dex`, with the - component name map at `assets/quickbuild/components.json`. +- **The baseline is inside the APK, not on disk** - `assets/quickbuild/gen-0.dex`, with its + stamped generation beside it at `assets/quickbuild/baseline-generation.txt`. Component names + reach CoGo through the `manifest-info.json`/`setup.json` build intermediates, not an APK asset. ## 5. bench-events.jsonl is the session as data, and needs the bench flag diff --git a/quickbuild/docs/pipeline.md b/quickbuild/docs/pipeline.md index b859f1d261..7de90fa30a 100644 --- a/quickbuild/docs/pipeline.md +++ b/quickbuild/docs/pipeline.md @@ -180,7 +180,7 @@ Five `DefaultTask` classes, all in one file, [QuickBuildTasks.kt](../../gradle-p | Task | What it produces | | -------------------------------- | ------------------------------------------------------------ | -| `QuickBuildGenerateSourcesTask` | transforms AGP's `MERGED_MANIFEST` in place, and emits the proxy `.java` sources, the `components.json` asset and `manifest-info.json` from that one input | +| `QuickBuildGenerateSourcesTask` | transforms AGP's `MERGED_MANIFEST` in place, and emits the proxy `.java` sources and the `manifest-info.json` intermediate (read by the later tasks, not shipped in the APK) from that one input | | `QuickBuildPayloadTransformTask` | diverts every PROJECT-scope class out of the APK's classes pipeline into `payload-classes/`, handing the pipeline back a jar carrying only the R classes | | `QuickBuildPayloadDexTask` | javac's the proxy sources, then dexes proxies plus diverted classes into `assets/quickbuild/gen-0.dex` | | `QuickBuildBaselineGenerationTask` | writes `assets/quickbuild/baseline-generation.txt`, the generation the host allocated for this baseline (`-Pcotg.quickbuild.baselineGeneration`; unset stamps 0). Its own task so the per-provision stamp never invalidates the dex work | From 9eda36c2f415f2fa08e6814a4f0038b4ef29d51d Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 24 Aug 2026 00:58:38 -0700 Subject: [PATCH 3/4] ADFA-4128: de-benchmark the docs so they review on their own Specific benchmark figures and paths into the CodeOnTheGo-build-benchmark repo made a reader chase a second repo to follow an argument here. The headline is now "about a 5x median speedup"; the reasoning each figure supported stays. - README: the pass-specific device table becomes the generic headline claim; the three caveats keep their point without the counts. - Bench-repo ties dropped: corpus result paths, CoGo build ids, and run ids in low-spec-devices, perf-roadmap, component-proxying-design, reliability-gaps, pipeline and resource-updates. Device-vs-device comparisons and the FUSE storage figures stay - they measure a property of the hardware, not Quick Build's speedup, and each explains why nearby code exists. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W --- quickbuild/README.md | 25 +++++++------------- quickbuild/docs/component-proxying-design.md | 5 ++-- quickbuild/docs/low-spec-devices.md | 24 ++++++++----------- quickbuild/docs/perf-roadmap.md | 10 ++++---- quickbuild/docs/pipeline.md | 2 +- quickbuild/docs/reliability-gaps.md | 3 +-- quickbuild/docs/resource-updates.md | 2 +- 7 files changed, 28 insertions(+), 43 deletions(-) diff --git a/quickbuild/README.md b/quickbuild/README.md index 856f4b3ffb..4beedbe05d 100644 --- a/quickbuild/README.md +++ b/quickbuild/README.md @@ -2,20 +2,13 @@ Quick Build makes the on-device edit loop much faster. Tap the lightning-bolt button once and **CoGo** (Code On The Go, this IDE) installs a generated **proxy app** - a live-reloading build of the user's project. From then on every compatible save reaches the running app in seconds, with no Gradle build and no reinstall. The whole loop runs on device - edit, watch, compile, dex, deploy, reload. -From the ADFA-4128 benchmark pass of 2026-08-11, on CoGo dev build `C-d-0810-2347` - realistic edits drawn from a corpus of open-source apps, comparing Quick Build's save-to-live-reload against a standard *incremental* Gradle build of the same edit: - -| Device | Warm edits | Median save to live | Median incremental Gradle build | Speedup | p25-p75 | -| ------------------------------------------------------------ | -------------- | ------------------- | ------------------------------- | --------- | ------------- | -| **Galaxy A06** - 3.5 GB, entry-level (eight Cortex-A55 cores, no big core) | 79 over 24 apps | 2822 ms | 18401 ms | **6.53x** | 4.53x - 9.10x | -| **Galaxy A56** - 8 GB, current mid-range; our reference device | 76 over 23 apps | 1094 ms | 4662 ms | **4.35x** | 3.28x - 5.84x | - -Both devices together: **5.12x** over 155 edits, p25-p75 3.92x - 7.75x `[measured on a56, a06]`. Speedup is the median of per-edit paired ratios - each edit's standard build divided by its own Quick Build, same edit, same app, same device - which is the correct paired statistic and differs from dividing the two median columns. The speedup is largest on the slowest device. +Measured against a standard incremental Gradle build of the same edit on real devices, Quick Build gives **about a 5x median speedup** on a warm edit. The gain is bigger on slower phones. Three things that number does not say: -- **It is conditional on a save that live-reloaded.** Quick Build produced a reload on 155 of 192 attempted edits, 80.7% `[measured on a56, a06]`: 21 misses were its own compile or deploy failing, 12 were provisioning, and 4 were the classifier declining by design. +- **It is conditional on a save that live-reloaded.** Not every attempted edit produces one: compile or deploy can fail, provisioning can fail, and the classifier declines some edits by design. - **The Gradle side excludes the install and launch it needs**, which biases the comparison against Quick Build. -- **It is not always faster.** 2 of 155 edits lost, both a Java ABI change in `sora-editor-full`, at 0.65x on the A06 and 0.76x on the A56. And the first project open is slower, once per session: 70.3 s against 50.3 s for a standard Run on the A56 (1.40x slower), 262.2 s against 165.4 s on the A06 (1.59x slower) `[measured on a56, a06]`. +- **It is not always faster.** A Java ABI change in a Java-heavy app can lose to a standard build, and the first project open is slower, once per session. ## Goals @@ -24,7 +17,7 @@ Three things that number does not say: 3. **Avoid modifying the user's code.** We use a Gradle plugin to create the proxy app that works as a wrapper, and try not to modify any of the user's app otherwise. 4. **Good enough, but no need to be 100% compatible.** Where the proxy app cannot match the real app, make that clear to the user - see [the boundary](#edit-types-that-can-live-reload) and [Known limitations](#known-limitations-v1). We're not trying to match a Gradle build exactly, just to be useful. 5. **Accept some tradeoffs to make live reload fast, but try to reduce tradeoffs** - 1. A reasonable amount of extra time at project open is OK - today the first open costs ~20 s more than a standard Run's first build on the A56 `[measured on a56]`. + 1. A reasonable amount of extra time at project open is OK - today the first open costs noticeably more than a standard Run's first build. 2. We need some memory to keep Quick Build's compile daemon resident and available. 6. **Runs offline, on device.** Same standard as Code on the Go. @@ -210,7 +203,7 @@ How the triggers get sequenced: - **One build in flight.** Starting a build *moves* the pending set into it; the set clears only on success and a failed batch is unioned back, so saves arriving mid-build simply join the next one. New work never cancels a running compile - it waits. - **Stale work cannot apply itself.** Every result carries its build id, and two epochs (session and daemon) guard every async result, so a build superseded by a teardown or a baseline reset is discarded rather than rendered. - **A deploy racing a reconnect is safe**, because the proxy app takes a payload only if it is strictly newer than what it runs. -- **The warm compile is what makes the first save fast** - worth 6.1x on it `[measured on a56]` ([`docs/perf-roadmap.md`](docs/perf-roadmap.md)). It starts only after `Ready` is reached, so it costs nothing on the way there. +- **The warm compile is what makes the first save fast** ([`docs/perf-roadmap.md`](docs/perf-roadmap.md)). It starts only after `Ready` is reached, so it costs nothing on the way there. - **Standard Run contention is gated, not locked.** The one Gradle slot answers `SlotBusy` as a distinct outcome rather than a build failure, and the device's single install slot (one install per `applicationId`, shared by the proxy app and a Standard Run install) is confirmed statelessly before either side clobbers the other. It goes both ways: any completed Standard Run build hands state back to a live session, refreshing or invalidating its baseline. Which threads exist, what each gate does, the mid-build sequence in full, and the reliability-mechanism table: [`docs/concurrency.md`](docs/concurrency.md). @@ -251,7 +244,7 @@ A reload swaps the payload classloader plus the resource apk and restarts compon ### Build Scratch Lives in Faster Private Storage -The daemon's work and out trees live in CoGo's `noBackupFilesDir`, not the project tree: the project sits on FUSE-backed shared storage, and moving off it cut warm edits by ~36% subset-median `[measured on a56]`. Cost: not user-browsable, so the tree carries a 100 MB guard, teardown deletion and a stale sweep. The generation counter deliberately stays in the project tree so it survives scratch cleanup. +The daemon's work and out trees live in CoGo's `noBackupFilesDir`, not the project tree: the project sits on FUSE-backed shared storage, and moving off it measurably cut warm-edit time `[measured on a56]`. Cost: not user-browsable, so the tree carries a 100 MB guard, teardown deletion and a stale sweep. The generation counter deliberately stays in the project tree so it survives scratch cleanup. ### Benchmarking Corpus Lives in Separate Repo @@ -274,7 +267,7 @@ Unit and Kaspresso tests cover CoGo itself; anything that crosses into the proxy - **Run the unit tests.** They live in each module's `src/test` - `:quickbuild:core` carries most of them (the domain layer is pure JVM by design), with more in `:quickbuild:daemon` and `:gradle-plugin`. Run them with `flox activate -d flox/local -- ./gradlew :quickbuild:core:test :quickbuild:daemon:test :gradle-plugin:test`. - **Script a session over `adb`.** Under the `CodeOnTheGo.qbbench` flag an exported activity opens a project and fires the first tap in place of a human, so a whole session - including a retry after an install-confirm timeout - runs unattended. Command and options: [`docs/debugging.md` §6](docs/debugging.md). -- **Run the corpus.** The `CodeOnTheGo-build-benchmark` repo carries the open-source app corpus, realistic edits and the E2E harness. Correctness comes from its two oracles - recompiled-class bounds and output equivalence - not from timings. Commit the results dir for any compile-pipeline change, and cite one for any latency claim. +- **Run the corpus.** The `CodeOnTheGo-build-benchmark` repo carries the open-source app corpus, realistic edits and the E2E harness. Correctness comes from its two oracles - recompiled-class bounds and output equivalence - not from timings. Commit the results dir for any compile-pipeline change. A new edit class or route needs all three: a classifier test, a corpus edit declaring `expected.route`, and an on-device walk if it deploys. Two traps: the root build sets `ignoreFailures = true` on test tasks only for analysis invocations (`sonar`, `sonarqube`, `jacocoAggregateReport`) - an ordinary test run gates on failures, but on an analysis run read `/build/test-results/` rather than trusting `BUILD SUCCESSFUL`; and nothing runs the real daemon jar against the real client ([`DaemonProcessClientTest`](core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientTest.kt) drives a scripted fake), so a protocol regression that compiles only surfaces on device. @@ -332,7 +325,7 @@ Everything ships as an APK asset - **there is no push-a-jar shortcut for any com | **The API 28/29 resource-swap path has never run on a device** | Android 9/10 take the legacy `addAssetPath` shim instead of `ResourcesLoader`. Only its failure branch is JVM-tested; the success path needs a real 28/29 device and none of our test devices is one `[unverified on device]`. Candidate for closing it: a targeted instrumented test on the farm's `SM_J737A` (API 28, arm32). | | **A deleted asset stays readable until the next proxy app rebuild** | The API 30+ asset overlay (a `DirectoryAssetsProvider` on the shared `ResourcesLoader`) can add and replace but cannot hide baked-in assets, so new and modified assets live-reload while a deletion lands only on the next proxy app rebuild. Content an app read and cached before the recreate stays stale until its process restarts, same as resources. On API 28/29 nothing serves a deployed asset payload, so asset-bearing edits route to the standard Gradle build instead - never stale, at full-build cost `[unverified on device]`. | | **A Gradle 9 start-up failure, contested and never re-run** | The setup build threw `UnknownPluginException` from CoGo's init-script plugin injection against a Gradle 9 project. This was **not** an incidental one-off: the corpus work isolated the variable, substituting Gradle 9.5.1 into `gradle-plugin`'s own AGP 8.11.0 fixture and reproducing the same failure, and concluded it blocks the setup build for **any** project pinned to Gradle 9+. Against that, `AndroidIDEInitScriptPluginTest` is now parameterized on 8.14.3 and 9.5.1 and passes. So either the wall is fixed or the TestKit fixture does not reproduce real injection against a real multi-module project - **no Gradle 9 project has been re-tried since the test went green** `[unverified]`. Matters beyond the corpus: sora-editor pins Gradle 9.5.1 / AGP 9.2.1 and KISS pins 9.4.1, and new projects increasingly pin 9. | -| **A library-module edit takes a full rebuild and an install tap** | ~25 s plus an install tap, against ~2.55 s for an app-module edit measured the same way - both from an earlier pass, not the one in the table above `[measured on a56, earlier pass]` (the 2026-08-11 pass medians 1094 ms for an app-module edit); the prompt fires per out-of-scope edit rather than once per session. Every module's `src` stays watched, so nothing is silently dropped. | +| **A library-module edit takes a full rebuild and an install tap** | ~25 s plus an install tap, against ~2.55 s for an app-module edit measured the same way, both from an earlier pass `[measured on a56, earlier pass]`; the prompt fires per out-of-scope edit rather than once per session. Every module's `src` stays watched, so nothing is silently dropped. | | **A Kotlin/Java corpus failure the tests contradict** | `IncrementalCompilerTest` compiles the same cycle cleanly, yet a sora-editor corpus run failed on this axis. A cross-*module* relationship would route to a rebuild anyway, which may be what was really seen. Not re-run `[unverified]`. | | **Quick Build needs more RAM than CoGo itself** | Works on both 4 GB-tier devices we own; at 1.9 GB it never provisions, and what fails is the Gradle build every session starts with `[measured on itel]`. The live reload loop has never failed on its own at any tier. Detail: [`docs/low-spec-devices.md`](docs/low-spec-devices.md). | | **Room-template apps cannot build offline at all** | A CoGo bundle dependency gap that fires before Quick Build is involved, so it is a bundle fix, not one here. The worst gap for an offline-first product `[measured on a56]`. | @@ -366,5 +359,5 @@ Design notes live in [`docs/`](docs/); repo-level ADRs are elsewhere, at [`docs/ Three things live outside this repo: -- **The benchmark corpus, harness and results**, in the standalone `CodeOnTheGo-build-benchmark` repo - every `corpus/...` path above maps into it. It drives CoGo only through the declared interfaces, so it cannot mask a break in them. Methodology and the QA records (low-spec runbook, template sweep, commit survey) are there too. +- **The benchmark corpus, harness and results**, in the standalone `CodeOnTheGo-build-benchmark` repo. It drives CoGo only through the declared interfaces, so it cannot mask a break in them. Methodology and the QA records (low-spec runbook, template sweep, commit survey) are there too. - **History** - earlier revisions of these docs in the archived tag `adfa-4128-history-20260731`, design history in Jira ADFA-4128. diff --git a/quickbuild/docs/component-proxying-design.md b/quickbuild/docs/component-proxying-design.md index 6d237380fe..cdbfacc020 100644 --- a/quickbuild/docs/component-proxying-design.md +++ b/quickbuild/docs/component-proxying-design.md @@ -268,9 +268,8 @@ restart rule and the skew guard. `PROXY_APP_WONT_STAY_UP`, none of which fit), so the only account of it is the build log `[inferred]`. - Evidence: corpus app `notes` on both devices - (`corpus/results/20260725T161105Z-e2e-bench/notes__provision.logcat.txt`). Its *standard* - build succeeds on the A56, which proves this is a Quick Build limitation and not an app defect + Evidence: a corpus app whose *standard* build succeeds on the A56 but whose provisioning fails, + which proves this is a Quick Build limitation and not an app defect `[measured on a56, measured on c107]`. - **A runtime-only library component still has to be excluded by name.** The manifest transform searches the variant's DEPENDENCY artifacts, which resolve without compiling anything; diff --git a/quickbuild/docs/low-spec-devices.md b/quickbuild/docs/low-spec-devices.md index bfe01e895d..3eddf8da64 100644 --- a/quickbuild/docs/low-spec-devices.md +++ b/quickbuild/docs/low-spec-devices.md @@ -13,9 +13,8 @@ devices now measure at 4 GB nominal and both run a full session, so that tier is effort pays off. The Gradle-free-provisioning spike below is the 1.9 GB answer and is not being scoped now - it stays on this page as the evidence for whoever picks that ticket up. -Primary evidence, with the full runbook and cost tables: -`corpus/results/analysis/c107-lowend-report-2026-07-25.md` in the `CodeOnTheGo-build-benchmark` -repo. +Primary evidence, with the full runbook and cost tables, lives in the +`CodeOnTheGo-build-benchmark` repo. ## What was actually measured @@ -34,10 +33,8 @@ repo. measured on both, its speedup is higher on all 19 (median 1.77x), saving a median 17.5 s per edit against 3.1 s on the A56 `[measured on c107, earlier pass]`. The C107 has not been in a pass since, so this row is historical - no current-pass C107 data exists. -- **The A06 shows the same pattern, and it holds in the current pass.** In the ADFA-4128 benchmark - pass of 2026-08-11 (CoGo build `C-d-0810-2347`), Quick Build beats the standard incremental build - by a median **6.53x** over 79 edits across 24 apps on the A06, against **4.35x** over 76 edits - across 23 apps on the A56 `[measured on a06, a56]`. +- **The A06 shows the same pattern, and it holds in the current pass.** Quick Build beats the + standard incremental build by a wider margin on the A06 than on the A56 `[measured on a06, a56]`. - **At the 4 GB tier, CPU decides the experience, not RAM** - but the two 4 GB devices have never been measured against each other on comparable terms. Each was compared to the A56 instead, and the two comparisons sit in different eras. Both rows are from earlier passes, superseded as @@ -46,16 +43,15 @@ repo. | Comparison | Build | Apps matched | Result | | --- | --- | --- | --- | - | C107 vs A56 | earlier pass, `C-d-0728-1154`, scratch on FUSE | 21 of 21 | C107 **3.5x** slower `[measured on c107, a56; earlier pass]` | - | A06 vs A56 | earlier passes, scratch off FUSE, `C-d-0802-0824` / `C-d-0731-2251` | 7 of 7 | A06 **2.5x** slower `[measured on a06, a56; earlier passes]` | + | C107 vs A56 | earlier pass, scratch on FUSE | 21 of 21 | C107 **3.5x** slower `[measured on c107, a56; earlier pass]` | + | A06 vs A56 | earlier passes, scratch off FUSE | 7 of 7 | A06 **2.5x** slower `[measured on a06, a56; earlier passes]` | - For scale in the current pass, unmatched: the A06's median save to live is 2822 ms against the - A56's 1094 ms, and its median standard incremental build 18401 ms against 4662 ms - `[measured on a06, a56]`. Different app sets, so this is not a paired cross-device ratio. + For scale in the current pass, unmatched: both the A06's median save to live and its median + standard incremental build are several times the A56's `[measured on a06, a56]`. Different app + sets, so this is not a paired cross-device ratio. Chaining those through the A56 puts the C107 at ~1.4x the A06, but the chain crosses the - scratch-off-FUSE change - worth 1.38x on the A56 alone across the same 7 apps - `[measured on a56]` - so treat the A06-vs-C107 gap as `[inferred]`, not measured. What is solid + scratch-off-FUSE change, so treat the A06-vs-C107 gap as `[inferred]`, not measured. What is solid is the ordering and that both 4 GB devices are usable. The A06's eight Cortex-A55 cores with no big core are the likely reason it still trails the A56 `[inferred]`. So "4 GB device" is not a performance class on its own - do not treat one 4 GB measurement as covering the tier. diff --git a/quickbuild/docs/perf-roadmap.md b/quickbuild/docs/perf-roadmap.md index 093d274096..49fc6f0fd6 100644 --- a/quickbuild/docs/perf-roadmap.md +++ b/quickbuild/docs/perf-roadmap.md @@ -1,6 +1,6 @@ # Performance roadmap -Where the remaining Quick Build latency lives, and which levers are worth pulling next. Everything here is stage timings, not the headline speedup - that lives in the [README's benchmark table](../README.md), from the ADFA-4128 benchmark pass of 2026-08-11 on CoGo build `C-d-0810-2347`. Lever 1 has shipped; the rest are ranked by ROI. +Where the remaining Quick Build latency lives, and which levers are worth pulling next. Everything here is stage timings, not the headline speedup - that lives in the [README](../README.md). Lever 1 has shipped; the rest are ranked by ROI. | # | Fix | Affects | Payoff per warm edit | Effort | Risk | Status | | --- | ------------------------------------------------- | ------------------------------ | ---------------------------------------------- | ------ | ---- | ----------- | @@ -31,7 +31,7 @@ Levers 3a/3b are designed in [`incremental-javac-design.md`](incremental-javac-d Nothing in the Quick Build compile path asks for a core count. The daemon is spawned as a bare `java -jar daemon.jar` with no JVM args and never goes through `GradleBuildTuner`, and d8 is invoked as the single-argument `D8.run(command)` with no `ExecutorService` and no `setThreadCount`. So the parallelism we get is whatever each tool defaults to, and nobody has checked what that is. -It ranks last because the stage that dominates a median warm edit is the one least able to use a second core: `kotlinc` is 53-60% of it (749 ms A56, 1500 ms A06 `[measured on a56, a06; CoGo build C-d-0809-0940]`), and single-file frontend analysis is largely serial. Two places it plausibly does pay, and they are what to investigate: +It ranks last because the stage that dominates a median warm edit is the one least able to use a second core: `kotlinc` is 53-60% of it (749 ms A56, 1500 ms A06 `[measured on a56, a06]`), and single-file frontend analysis is largely serial. Two places it plausibly does pay, and they are what to investigate: - **aapt2 link on resource edits - 1027 ms on the A56, 2074 ms on the A06**, larger than an entire @@ -85,9 +85,9 @@ Reference workload: `sora-editor-full` (288 sources: 214 `.java` + 74 `.kt`, 464 - The "53 s per edit" figure was never a per-edit cost. It was the session's first build, which now runs as a background warm compile before the user can save. -- The warm compile is what makes the *first* save fast, and it is worth **6.1x** on that save: +- The warm compile is what makes the *first* save fast: a warmed first save costs a fraction of an - **1.9 s warmed vs 11.5 s unwarmed**, almost all of it cold `kotlinc`. Matched on/off A/B, 3 trials per arm, one build, `hello-kotlin` (`corpus/results/20260728T153938Z-seed-ab/`) `[measured on a56]`. Tap-to-`Ready` is unchanged, because the warm compile starts after `Ready`. + unwarmed one, almost all of the difference cold `kotlinc`. Matched on/off A/B, 3 trials per arm, one build, `hello-kotlin` `[measured on a56]`. Tap-to-`Ready` is unchanged, because the warm compile starts after `Ready`. ## Not covered here @@ -100,5 +100,3 @@ Reference workload: `sora-editor-full` (288 sources: 214 `.java` + 74 `.kt`, 464 - **`readyou`** - a pure-Kotlin 6-file module measuring 13.7 s / 15.2 s before dropping to 2.9 s `[measured on a56]`. No javac, no large class tree; nothing above explains it. - -Evidence: `20260728T172912Z-sora-deepdive/` and `results/analysis/offfuse-comparison-2026-07-31.md` in `CodeOnTheGo-build-benchmark`. diff --git a/quickbuild/docs/pipeline.md b/quickbuild/docs/pipeline.md index 7de90fa30a..a1e3250963 100644 --- a/quickbuild/docs/pipeline.md +++ b/quickbuild/docs/pipeline.md @@ -642,7 +642,7 @@ The daemon ships as a ~62 MB zip inside CoGo's APK, almost all of it `kotlin-com - **R8 is ruled out.** `R8 --classfile` shrank the compiler to 37 MB and produced a non-functional daemon: every Kotlin compile died with a `NoClassDefFoundError` initializing a core CLI diagnostics class, because tree-shaking cut a static-init dependency reached only reflectively. A compiler is exactly the kind of code R8 cannot reason about. - **Dropping whole never-loaded backend subtrees does work**, and is a different operation - plain jar surgery, not tree-shaking. Every class that survives is byte-identical, so nothing that remains can break on a reflective lookup. It removed **~5.7 MB from the compressed daemon zip**. - The severable entry prefixes, all under `org/jetbrains/kotlin/`: `backend/wasm/`, `ir/backend/js/`, `backend/konan/`, `cli/js/`, `cli/metadata/`, `wasm/`, `js/`, `serialization/js/`, `konan/`, `library/`, `native/`. They are alternative-target codegen backends that a JVM-only incremental compile never loads - [IncrementalCompiler](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/compile/IncrementalCompiler.kt) always drives `K2JVMCompiler`, and Compose is JVM-IR, so it is covered too. -- **Verified severable by the full host corpus matrix** - 13 apps / 48 edits, output-equivalence PASS on all of them, including `compose-kotlin`, `mixed-lang` and the real `sora-editor-lib` slice (`quick-build/corpus/results/`). That matrix is the regression gate for any future attempt. +- **Verified severable by the full host corpus matrix** - 13 apps / 48 edits, output-equivalence PASS on all of them, including `compose-kotlin`, `mixed-lang` and the real `sora-editor-lib` slice. That matrix is the regression gate for any future attempt. ## Step 6: Deploy and reload (`:quickbuild:runtime`) diff --git a/quickbuild/docs/reliability-gaps.md b/quickbuild/docs/reliability-gaps.md index 32fbd05f9a..0fed3a536a 100644 --- a/quickbuild/docs/reliability-gaps.md +++ b/quickbuild/docs/reliability-gaps.md @@ -73,8 +73,7 @@ flowchart LR `ChangeClassifier.kt:47` then escalates unconditionally on `annotationImpact.active` ("project has any processor", not "this edit touched processor input"). Unnecessary: the changed-file set, the annotation baseline and on-disk sources are all still intact at that point. -- **Evidence** `[measured on a56, 2026-07-28]`: 3/3 reproductions, - `corpus/results/20260728T113213Z-task32-roomksp-online/DEVICE-FINDINGS.md:46-57`. +- **Evidence** `[measured on a56, 2026-07-28]`: reproduced 3 times out of 3. - **Likely fix:** reclassify from the preserved set instead of falling back to `Unknown` - must still distinguish "lost the daemon" from "lost track of files", since a genuinely unenumerable change has to escalate. diff --git a/quickbuild/docs/resource-updates.md b/quickbuild/docs/resource-updates.md index 940bc0ce9e..3bed79344d 100644 --- a/quickbuild/docs/resource-updates.md +++ b/quickbuild/docs/resource-updates.md @@ -1,6 +1,6 @@ # How resource updates are handled -What happens when the user saves a resource file while Quick Build is running, who consumes each result, and the design decisions around it. Terms (proxy app, payload, rebaseline, orchestrator) are defined in the [README](../README.md); the reload steps are in [pipeline.md](pipeline.md). Behavior verified on-device 2026-08-13 UTC (A56, CoGo C-d-0812-1737). +What happens when the user saves a resource file while Quick Build is running, who consumes each result, and the design decisions around it. Terms (proxy app, payload, rebaseline, orchestrator) are defined in the [README](../README.md); the reload steps are in [pipeline.md](pipeline.md). Behavior verified on-device 2026-08-13 UTC (A56). ## Two independent pipelines fire on a resource save From e6b643ab5f181bdbf80f13d004e82f68571f4f20 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 26 Aug 2026 23:43:43 -0700 Subject: [PATCH 4/4] ADFA-4128 (1/11): address CodeRabbit review - F1713-9 stop the pipeline diagrams implying the deploy decision reads the changed set Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FstXxJ5cwWPcvmhZ9vJgJ7 --- quickbuild/docs/pipeline.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/quickbuild/docs/pipeline.md b/quickbuild/docs/pipeline.md index a1e3250963..10447e183e 100644 --- a/quickbuild/docs/pipeline.md +++ b/quickbuild/docs/pipeline.md @@ -112,7 +112,7 @@ sequenceDiagram D-->>S: relinked resource apk end Note over S: GenerationTracker.next() -> gen N
(allocated ONLY after compile+dex succeed) - Note over S: DeployPolicy.decide(changedClasses)
-> Recreate (hot swap) + Note over S: DeployPolicy.decide()
keys on the app's declared components,
not on what this build recompiled
-> Recreate (hot swap) S->>Ch: deploy(gen N, dex, arsc?, assets?, meta) Note over Ch: subscribe to reports BEFORE the
oneway call, open payloads as read-only fds Ch->>App: onPayload(gen N, dexFd, arscFd, assetsFd, meta) @@ -488,7 +488,7 @@ flowchart TB subgraph decide["Deploy decision (inside the executed build)"] gen["GenerationTracker.next()
persist-before-return; gaps ok, reuse never"] - pol["DeployPolicy.decide(changedClasses)"] + pol["DeployPolicy.decide()
keys on declared components; the recompiled
set only picks the pre-v2 fallback
"] rec["Recreate (hot swap)"]:::out res["Restart (service/provider/Application
in supertype closure)"]:::out reb["RebuildProxyApp (pre-v2 baseline)"]:::out