From 7e99a041a235e29c34eff0fb47744eb74f6ae2b4 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 15:03:33 -0700 Subject: [PATCH 1/2] =?UTF-8?q?ADFA-4128:=20qb=2008/12=20core-orchestratio?= =?UTF-8?q?n=20=E2=80=94=20Core=20slice=204:=20the=20session=20state=20mac?= =?UTF-8?q?hine=20tying=20the=20slices=20together;=20every=20transition=20?= =?UTF-8?q?narrated?= 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 --- .../domain/session/QuickBuildSessionState.kt | 543 ++ .../domain/session/QuickBuildStatus.kt | 167 + .../domain/session/QuickBuildTone.kt | 70 + .../cotg/quickbuild/domain/session/README.md | 74 + .../domain/session/SessionReducer.kt | 714 +++ .../service/provision/ProxyAppBuildRunner.kt | 460 ++ .../service/session/LiveReloadExecutorImpl.kt | 537 ++ .../quickbuild/service/session/LiveSession.kt | 123 + .../service/session/LiveSessionFactory.kt | 191 + .../session/OrchestratorEventRouter.kt | 161 + .../service/session/QuickBuildHistoryStore.kt | 25 + .../session/QuickBuildSessionManager.kt | 1425 +++++ .../cotg/quickbuild/service/session/README.md | 13 + .../domain/session/FailedStartToneTest.kt | 133 + .../domain/session/QuickBuildStatusTest.kt | 174 + .../domain/session/QuickBuildToneTest.kt | 125 + .../domain/session/SessionReducerTest.kt | 1449 +++++ .../cotg/quickbuild/service/Fakes.kt | 28 + .../service/deploy/PayloadDeployerEdgeTest.kt | 183 + .../provision/ProxyAppBuildRunnerEdgeTest.kt | 285 + .../provision/ProxyAppBuildRunnerTest.kt | 447 ++ .../session/LiveReloadExecutorImplEdgeTest.kt | 259 + .../session/LiveReloadExecutorImplTest.kt | 1670 ++++++ .../session/LiveSessionAdoptBaselineTest.kt | 191 + .../service/session/LiveSessionFactoryTest.kt | 251 + .../OrchestratorEventRouterEdgeTest.kt | 53 + .../session/OrchestratorEventRouterTest.kt | 184 + .../session/QuickBuildSessionManagerTest.kt | 4874 +++++++++++++++++ 28 files changed, 14809 insertions(+) create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildSessionState.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildStatus.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildTone.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/README.md create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/SessionReducer.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppBuildRunner.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImpl.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveSession.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveSessionFactory.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouter.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildHistoryStore.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManager.kt create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/README.md create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/session/FailedStartToneTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildStatusTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildToneTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/session/SessionReducerTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerEdgeTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppBuildRunnerEdgeTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppBuildRunnerTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImplEdgeTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImplTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/LiveSessionAdoptBaselineTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/LiveSessionFactoryTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouterEdgeTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouterTest.kt create mode 100644 quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManagerTest.kt diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildSessionState.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildSessionState.kt new file mode 100644 index 0000000000..364bc21889 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildSessionState.kt @@ -0,0 +1,543 @@ +package org.appdevforall.cotg.quickbuild.domain.session + +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic + +/** + * Lifecycle states of a quick-build session, as one sealed type rather than a set of booleans. + * + * The generation carried by the live states is the one the PROXY APP currently runs, which is + * what the "running gen N" line reports. A compile error keeps the session in [Ready] at the + * old generation with [Ready.lastFailure] set; the proxy app never moved. + */ +sealed interface QuickBuildSessionState { + /** + * No session. The Quick Build button starts provisioning. + * + * @property lastStartFailed the last transition into Idle was [SessionEvent.ProvisioningFailed], + * so the bolt keeps the error tone instead of settling back to a green READY right after the + * failure flash. Cleared by the next tap (which starts a fresh provision anyway) or the next + * save ([SessionEvent.FileSaved]) - the save clears the tone only and never retries the + * start; a retry stays a tap. + */ + data class Idle( + val lastStartFailed: Boolean = false, + ) : QuickBuildSessionState + + /** + * The eager proxy app build is running in the background at project open - no install, no + * daemon, no session. + * + * @property tapQueued a Quick Build tap landed mid-warm, so provisioning starts when the warm + * build finishes; two concurrent Gradle builds through the tooling server would fail. + * @property lastStartFailed carried from [Idle.lastStartFailed] so the silent warm build does + * not clear the failed-start error tone on its way through; a queued tap clears it, and a + * tapless finish hands it back to [Idle]. + */ + data class Prebuilding( + val tapQueued: Boolean = false, + val lastStartFailed: Boolean = false, + ) : QuickBuildSessionState + + /** + * Proxy app build, proxy-app install and daemon spawn in progress. + * + * @property userInitiated a Quick Build tap started this, so the proxy app is brought forward on + * [SessionEvent.ProvisioningSucceeded]; false for a proxy app rebuild, which a plain save can + * trigger and which is answered by the deferred switch the shell holds, not by this flag. + * @property installAutoRetries carried through a proxy app rebuild so an unconfirmed reinstall + * parks back in [Invalidated] with the count intact (see [Invalidated.installAutoRetries]). + * @property rebaselineReason what invalidated the baseline when this is a rebaseline rather than + * a session's first provision, null for the first - carried in the state rather than inferred + * from the [Invalidated] hop before it, because the status surfaces read a conflating + * [kotlinx.coroutines.flow.StateFlow] and may never observe that hop. + */ + data class Provisioning( + val userInitiated: Boolean = false, + val installAutoRetries: Int = 0, + val rebaselineReason: InvalidationReason? = null, + ) : QuickBuildSessionState + + /** + * Session live, no build running. [lastFailure] is surfaced until the next build. + * + * @property generation the generation the proxy app currently runs. + * @property lastFailure why the previous build did not move that generation, or null when the + * last build landed; a compile error and a proxy-app crash both park here. + */ + data class Ready( + val generation: Long, + val lastFailure: SessionFailure? = null, + ) : QuickBuildSessionState + + /** + * A build is running; the proxy app still runs [deployedGeneration]. + * + * @property deployedGeneration the generation the proxy app still runs while this build is in + * flight; it only moves on a successful deploy. + * @property warmingCompiler the in-flight build is the background warm compile + * ([org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute.WarmCompile]), which deploys + * nothing, so the status must not present it as blocking and a tap must trigger a real build. + * @property pendingCrash a proxy-app crash seen mid-warm-compile, which + * [SessionEvent.WarmCompileFinished] lands as [Ready.lastFailure]; the warm compile + * suppresses its own outcome, not crashes of the running generation. + */ + data class Building( + val deployedGeneration: Long, + val warmingCompiler: Boolean = false, + val pendingCrash: SessionFailure.ProxyAppCrash? = null, + ) : QuickBuildSessionState + + /** + * A build just landed; the proxy app runs [generation]. + * + * @property generation the generation the deploy just moved the proxy app to. + * @property buildDurationMillis the whole save-to-live loop this deploy closed, in + * milliseconds - not the build alone; the status surface shows it as the "reloaded in" + * figure, so it has to be the span the user waited (see + * [org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome.Success.durationMillis]). + * @property restarted it landed via the process-restart path (service/provider/Application code + * changed), so the proxy app relaunched at its launcher and lost in-process state. + */ + data class Deployed( + val generation: Long, + val buildDurationMillis: Long, + val restarted: Boolean = false, + ) : QuickBuildSessionState + + /** + * The baseline is stale (manifest, gradle, or an external build) and needs a full Gradle + * build. + * + * @property reason what the live reload path could not absorb, which the status surface names + * to the user. + * @property deployedGeneration the generation the proxy app keeps running until a full rebuild + * replaces it. + * @property awaitingRetry no proxy app rebuild is in flight, so the next Quick Build tap or + * [SessionEvent.HostForegrounded] retries it instead of the session dying to [Idle]. + * @property installAutoRetries how many [SessionEvent.HostForegrounded] auto-retries this + * unconfirmed reinstall has spent; at [SessionReducer.MAX_INSTALL_AUTO_RETRIES] the foreground + * trigger stops, so a user who keeps declining does not pay a Gradle build on every resume, + * while an explicit tap still retries and resets the budget. + */ + data class Invalidated( + val reason: InvalidationReason, + val deployedGeneration: Long, + val awaitingRetry: Boolean = false, + val installAutoRetries: Int = 0, + ) : QuickBuildSessionState + + /** + * The compile daemon died; respawn and warm compile in progress. + * + * @property deployedGeneration the generation the proxy app keeps running across the daemon + * outage - the process is untouched, only the compiler is gone. + * @property restartFailed the respawn did not stick - it failed outright + * ([SessionEvent.DaemonRestartFailed]) or the fresh daemon died again - and nothing is + * scheduled to try once more, deliberately, because auto-retrying a hard-broken daemon just + * spins; a flag rather than a state because all that changes is that the status must stop + * claiming a restart is under way. + */ + data class Degraded( + val deployedGeneration: Long, + val restartFailed: Boolean = false, + ) : QuickBuildSessionState +} + +/** Why the last quick build did not move the proxy app to a new generation. */ +sealed interface SessionFailure { + /** + * The changed sources did not compile, so nothing was deployed. + * + * @property diagnostics the compiler messages for this build, in the order the daemon reported + * them; read [BuildDiagnostic.severity] rather than assuming every entry is an error. + */ + data class CompileError( + val diagnostics: List, + ) : SessionFailure + + /** + * The sources compiled but the payload never reached the proxy app. + * + * @property message why the deploy or reload failed, already user-facing - the status surface + * shows it verbatim. + */ + data class DeployError( + val message: String, + ) : SessionFailure + + /** + * The payload crashed in the proxy app (render or lifecycle), not a compile error. + * + * @property summary short description of the crash, from the runtime's report rather than a + * full stack trace. + */ + data class ProxyAppCrash( + val summary: String, + ) : SessionFailure +} + +/** Inputs to [SessionReducer], from the UI, the orchestrator, and process observers. */ +sealed interface SessionEvent { + /** + * The user tapped the Quick Build button. + * + * @property wroteSomething whether the tap's save-all wrote at least one file - the single + * bit the tap carries across the save/watch boundary. The watcher stays the only + * changeset source, so no filenames travel with the tap: a true bit routes the tap + * through the batch those writes will produce, a false bit with nothing pending answers + * the tap by switching without building. States that do not trigger a live reload + * ignore it. + */ + data class QuickBuildTapped( + val wroteSomething: Boolean = false, + ) : SessionEvent + + /** + * The user tapped the button while it showed the stop affordance. + * + * Only states that own a build the user asked for act on it, so the shell can dispatch it + * without checking. + */ + data object CancelRequested : SessionEvent + + /** + * The editor wrote a file to disk - the host-side save path, not the session's watcher. + * + * Only [QuickBuildSessionState.Idle] with `lastStartFailed = true` acts on it, clearing the + * stale error tone without retrying the start (a retry stays a tap). Every other state + * ignores it: a live session learns about saves from its own watcher, and this event must + * never start a build. + */ + data object FileSaved : SessionEvent + + /** Project opened with the feature enabled: warm the proxy app build, defer the install. */ + data object PrebuildRequested : SessionEvent + + /** The eager proxy app build finished; a warm failure is not surfaced. */ + data object PrebuildFinished : SessionEvent + + /** + * The session is live at [generation]. + * + * @property generation the generation the freshly installed proxy app starts at; every later + * deploy must be strictly newer. + */ + data class ProvisioningSucceeded( + val generation: Long, + ) : SessionEvent + + /** + * Provisioning failed; the session drops to [QuickBuildSessionState.Idle] with + * `lastStartFailed = true` and surfaces [message]. + * + * @property message why it failed, already user-facing - it is shown verbatim. + */ + data class ProvisioningFailed( + val message: QuickBuildMessage, + ) : SessionEvent + + /** A real quick build started; its deploy will move the generation. */ + data object BuildStarted : SessionEvent + + /** + * The background warm compile started ([org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute.WarmCompile]). + * + * A distinct event rather than a flag on [BuildStarted] so the session can mark itself + * `warmingCompiler`, which keeps the status surface reading "up to date" and keeps taps and + * crashes during the window handled honestly (see [QuickBuildSessionState.Building]). + */ + data object WarmCompileStarted : SessionEvent + + /** + * A build deployed; the proxy app now runs [generation]. + * + * @property generation the generation now live in the proxy app, always newer than the one it + * replaced. + * @property durationMillis the whole save-to-live loop this deploy closed, in milliseconds - + * not the build alone. + * @property restarted true when the deploy restarted the proxy-app process (component code + * changed). + * @property userInitiated true when this build answers a Quick Build tap, so the deploy landing + * is the moment to bring the proxy app forward; false for a build a file write + * triggered - a save is not the user asking to leave the editor - and for a cancelled tap. + */ + data class BuildSucceeded( + val generation: Long, + val durationMillis: Long, + val restarted: Boolean = false, + val userInitiated: Boolean = false, + ) : SessionEvent + + /** + * A build did not deploy; the proxy app stays on its current generation. + * + * @property failure why it did not land; surfaced as [QuickBuildSessionState.Ready.lastFailure] + * until the next build supersedes it. + */ + data class BuildFailed( + val failure: SessionFailure, + ) : SessionEvent + + /** + * The background warm compile finished, whether green or failed. + * + * Nothing deployed and the generation did not move, so no warm-compile outcome is surfaced: it + * recompiled sources that already built green. A proxy-app crash seen during the window is not + * a warm-compile outcome and lands as [QuickBuildSessionState.Ready.lastFailure]; daemon death + * stays on the [DaemonDied] path. + */ + data object WarmCompileFinished : SessionEvent + + /** + * A change the live reload path cannot absorb; the baseline is now stale. + * + * @property reason what could not be absorbed; reported once per invalidation, so no state may + * silently drop this event. + */ + data class InvalidationDetected( + val reason: InvalidationReason, + ) : SessionEvent + + /** The full Gradle proxy app rebuild has been kicked off. */ + data object ProxyAppRebuildStarted : SessionEvent + + /** + * The proxy app rebuild built fine but its reinstall was never confirmed - no dialog could be + * shown, the user cancelled, or it went untapped until the installer timed out. + * + * The session is not dead: it parks in [QuickBuildSessionState.Invalidated] with + * `awaitingRetry = true`, where the next tap or [HostForegrounded] rebuilds and re-prompts. + * + * @property deployedGeneration the generation the proxy app still runs, carried through the + * park so the parked state keeps reporting it. + */ + data class ProxyAppRebuildInstallNotConfirmed( + val deployedGeneration: Long, + ) : SessionEvent + + /** + * A parked rebuild retry never started because the device's single Gradle slot was taken. + * + * The session parks straight back awaiting a retry, and the attempt is NOT charged against + * [QuickBuildSessionState.Invalidated.installAutoRetries]: that budget bounds Gradle builds and + * install prompts, and a deferred attempt produced neither. The collision is routine - the + * gradle-file change that parks a session is also what makes CoGo declare NEED_SYNC. + * + * @property deployedGeneration the generation the proxy app still runs, carried through the + * park so the parked state keeps reporting it. + */ + data class ProxyAppRebuildDeferred( + val deployedGeneration: Long, + ) : SessionEvent + + /** + * The full Gradle build behind a proxy app rebuild failed: the user's build files do not build. + * + * Distinct from [ProvisioningFailed], which drops to [QuickBuildSessionState.Idle] because there + * is no session to keep. Here there IS one, running perfectly well, and the cause is a file the + * user can fix in seconds - so the session parks in [QuickBuildSessionState.Invalidated] + * awaiting a retry instead of dying, as a broken source file already does. + * + * @property reason the invalidation that asked for the rebuild, carried so the park keeps + * naming it. + * @property deployedGeneration the generation the proxy app still runs; the failed build + * deployed nothing. + */ + data class ProxyAppRebuildFailed( + val reason: InvalidationReason, + val deployedGeneration: Long, + ) : SessionEvent + + /** + * CoGo's editor came (back) to the foreground - the first chance to re-prompt a missed install. + * + * Only meaningful to a session parked in [QuickBuildSessionState.Invalidated] with + * `awaitingRetry = true`: when the reinstall ran while CoGo was backgrounded, Android defers the + * PENDING_USER_ACTION broadcast until the app returns, and the EventBus dialog subscriber + * (registered onStart) can re-register after that delivery lands, so no dialog is launched. + */ + data object HostForegrounded : SessionEvent + + /** + * A full Gradle build ran outside the session (a Standard Run) and completed. + * + * It may have regenerated `build/` inputs the watcher cannot see, so a live session must + * refresh its baseline from current disk before its next build. + */ + data object ExternalBuildCompleted : SessionEvent + + /** The compile daemon died. */ + data object DaemonDied : SessionEvent + + /** The compile daemon is back and warm. */ + data object DaemonRespawned : SessionEvent + + /** + * A respawn attempt failed, so the compiler is down with nothing scheduled to bring it back. + * + * The manager already shows [QuickBuildMessage.DaemonRestartFailed] when this happens; the + * event exists so the STATUS can stop saying "compile daemon restarting" as well, which after + * a failed respawn asserts an activity that is not happening. Only + * [QuickBuildSessionState.Degraded] acts on it. + */ + data object DaemonRestartFailed : SessionEvent + + /** + * The proxy app process crashed. + * + * @property summary short description of the crash, carried into + * [SessionFailure.ProxyAppCrash] rather than shown as a stack trace. + */ + data class ProxyAppCrashed( + val summary: String, + ) : SessionEvent + + /** + * Tear the session down and leave it Idle. Valid from any state. + * + * The internal half of the escape hatch, for callers that want the session gone and nothing + * started in its place - closing a project, or a Standard Run about to install over the proxy + * app. A user who asked to restart wants [SessionRestartAndReprovisionRequested] instead. + */ + data object SessionRestartRequested : SessionEvent + + /** + * Tear the session down and immediately provision a fresh one. Valid from any state. + * + * What the "Restart session" menu item and the proxy-app-won't-stay-up dialog mean: every notice + * naming Restart session as the remedy ([QuickBuildNotice.RELOAD_CRASHED], + * [QuickBuildNotice.RELINK_STUCK], [QuickBuildNotice.PROXY_APP_WONT_STAY_UP]) needs a fresh + * proxy app build to deliver it, and stopping at Idle instead leaves an unchanged toolbar icon + * and a second tap for the user to discover (T15). + */ + data object SessionRestartAndReprovisionRequested : SessionEvent +} + +/** Side effects the session manager must run after a transition. */ +sealed interface SessionEffect { + /** Build, install and start a session from scratch. */ + data object StartProvisioning : SessionEffect + + /** Run the proxy app build only - no install, no daemon. */ + data object StartProxyAppPrebuild : SessionEffect + + /** + * Ask the orchestrator to build now. + * + * @property userInitiated carries who asked all the way to the deploy, deliberately separate + * from [org.appdevforall.cotg.quickbuild.domain.reload.BuildRequest.forced], which the + * reconnect catch-up also sets and which is re-armed after a failure - reusing it would pull + * the user out of the editor on a stale reconnect or on a save retrying a failed tap. + * @property expectChanges the tap's save-all wrote at least one file, so the orchestrator + * should wait for the watcher batch those writes produce instead of building an empty + * set (see [SessionEvent.QuickBuildTapped.wroteSomething]); meaningless when + * [userInitiated] is false. + */ + data class TriggerLiveReload( + val userInitiated: Boolean, + val expectChanges: Boolean = false, + ) : SessionEffect + + /** + * Bring the proxy app to the foreground - the answer to a tap. + * + * Never emitted for a build a file write triggered, nor after a cancelled tap. + */ + data object SwitchToProxyApp : SessionEffect + + /** + * Record that a tap landed on a real build already in flight, so its deploy brings the proxy + * app forward. + * + * Deliberately not [TriggerLiveReload]: the in-flight build is about to do the same work, so + * forcing a second rebuild behind it would double the cost for nothing. + */ + data object MarkBuildUserInitiated : SessionEffect + + /** + * Stop the in-flight incremental quick build. + * + * The reducer has already returned to [QuickBuildSessionState.Ready] at the unchanged + * generation, so nothing new deploys. + */ + data object CancelLiveReload : SessionEffect + + /** + * Stop the out-of-process Gradle proxy app build (prebuild, provision or rebuild). + * + * Cancelling the awaiting coroutine alone leaves Gradle running to completion, so this has to + * reach the tooling server's cancellation token. + */ + data object CancelProxyAppBuild : SessionEffect + + /** + * Start the background warm compile ([org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute.WarmCompile]) as soon as a session goes live. + * + * Pays the daemon's first-compile warm-up (kotlinc JIT, classpath snapshot, IC-cache build) in + * the provisioning tail instead of on the user's first save. + */ + data object StartWarmCompile : SessionEffect + + /** Route to the real Gradle build; on completion the session rebuilds its proxy app. */ + data object RunProxyAppRebuild : SessionEffect + + /** + * Recover the live session's baseline after an external full build. + * + * The shell chooses: mark the incremental baseline dirty so the next build recompiles from + * current disk, or - if the external build clobbered the proxy app artifacts - escalate to a + * full rebuild with [InvalidationReason.EXTERNAL_FULL_BUILD]. + */ + data object RefreshBaseline : SessionEffect + + /** Bring the compile daemon back up after it died. */ + data object RespawnDaemon : SessionEffect + + /** + * Show the user why provisioning failed. + * + * @property message the wording to show, already user-facing - the shell does not rephrase it. + */ + data class SurfaceProvisioningError( + val message: QuickBuildMessage, + ) : SessionEffect + + /** + * Show the user a failure and leave the session running. + * + * The counterpart to [SurfaceProvisioningError], which tears the session down: this one is for + * a state that is recoverable, where the message explains what just happened rather than what + * killed the session. + * + * @property message the wording to show, already user-facing - the shell does not rephrase it. + */ + data class SurfaceMessage( + val message: QuickBuildMessage, + ) : SessionEffect + + /** Tear down the live session and daemon; the reducer has already moved to Idle. */ + data object TeardownSession : SessionEffect + + /** + * Tear the live session down and then provision a fresh one, in that order. + * + * One effect rather than [TeardownSession] followed by [StartProvisioning] because the teardown's + * daemon shutdown is asynchronous: as two effects the new session could start a daemon into a + * shutdown still in flight, which would then kill the daemon it just spawned. Only the + * user-facing restart pays for that wait; an ordinary tap after a teardown deliberately does not + * (the shell's scratch-tree handling is what makes that case safe). + */ + data object TeardownAndProvision : SessionEffect +} + +/** + * The reducer's output: the state to adopt and the effects the shell must then run. + * + * @property state the state to adopt; equal to the input state when the event was a no-op. + * @property effects the effects to run after adopting [state], in order; empty for a no-op. + */ +data class SessionTransition( + val state: QuickBuildSessionState, + val effects: List = emptyList(), +) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildStatus.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildStatus.kt new file mode 100644 index 0000000000..d7826b8126 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildStatus.kt @@ -0,0 +1,167 @@ +package org.appdevforall.cotg.quickbuild.domain.session + +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason + +/** + * What the status surface should show, derived purely from session state rather than set and + * cleared imperatively. + * + * Deriving it makes a stuck banner unrepresentable: every state maps to exactly one status, so + * every terminal state clears the transient one. A banner cleared only on successful render + * would leave "Compiling..." up forever after a compile error or a payload crash. + */ +sealed interface QuickBuildStatus { + /** + * No session - nothing in progress to narrate. + * + * @property lastStartFailed the last session start failed + * ([QuickBuildSessionState.Idle.lastStartFailed]), so the bolt keeps the error tone until + * the next tap or save; carried here because a failed start rests in Hidden and the tone + * is derived from status alone. + */ + data class Hidden( + val lastStartFailed: Boolean = false, + ) : QuickBuildStatus + + /** + * Proxy app build, install and daemon spawn in progress. + * + * @property rebaselineReason what invalidated the old baseline, or null on a session's first + * provision; it has to travel in the status because this conflating + * [kotlinx.coroutines.flow.StateFlow] lets a surface miss the [NeedsFullBuild] that preceded a + * rebaseline and then call it "the initial full build". + */ + data class Provisioning( + val rebaselineReason: InvalidationReason? = null, + ) : QuickBuildStatus + + /** + * A build is running; the proxy app still runs [runningGeneration]. + * + * @property runningGeneration the generation live in the proxy app right now, one behind the + * build in flight. + */ + data class Building( + val runningGeneration: Long, + ) : QuickBuildStatus + + /** + * The proxy app is running the latest edit. + * + * @property generation the generation the proxy app runs, which is also the latest built. + * @property buildDurationMillis how long the landed save-to-live loop took, in milliseconds - + * the whole wait, not the build alone; null when no build landed in this session yet, and + * the surface then shows no timing. + * @property restarted the deploy relaunched the proxy-app process (service/provider/Application + * code changed), so the surface phrases it as a restart rather than a plain reload. + */ + data class UpToDate( + val generation: Long, + val buildDurationMillis: Long?, + val restarted: Boolean = false, + ) : QuickBuildStatus + + /** + * The edit did not land; the proxy app still runs [runningGeneration]. + * + * @property runningGeneration the generation still live in the proxy app - a failure never + * moves it. + * @property failure what went wrong: a compile error, a failed deploy, or a crash of the + * running generation. + */ + data class Failed( + val runningGeneration: Long, + val failure: SessionFailure, + ) : QuickBuildStatus + + /** + * The baseline is stale; only a full Gradle build can move the proxy app forward. + * + * @property reason what the live reload path could not absorb, which the surface names to the + * user. + * @property runningGeneration the generation still live in the proxy app until the rebuild + * lands. + * @property awaitingRetry a rebaseline already ran and parked (build failed or install not + * confirmed), so the surface must read as a failure the user resolves rather than ordinary + * upcoming work; see [QuickBuildSessionState.Invalidated.awaitingRetry]. + */ + data class NeedsFullBuild( + val reason: InvalidationReason, + val runningGeneration: Long, + val awaitingRetry: Boolean = false, + ) : QuickBuildStatus + + /** + * The compile daemon died and is being respawned. + * + * @property runningGeneration the generation the proxy app keeps running through the outage - + * its process is untouched. + * @property restartFailed the respawn did not stick and nothing is retrying it, so the surface + * must name the gesture that brings the compiler back rather than claim a restart is in + * progress; see [QuickBuildSessionState.Degraded.restartFailed]. + */ + data class Reconnecting( + val runningGeneration: Long, + val restartFailed: Boolean = false, + ) : QuickBuildStatus + + companion object { + /** + * Maps a session state to the one status that represents it. + * + * @param state the current session state; every state maps, so no caller has to handle a + * missing status. + * @return the status to render, [Hidden] when the surface should show nothing. + */ + fun from(state: QuickBuildSessionState): QuickBuildStatus = + when (state) { + is QuickBuildSessionState.Idle -> { + Hidden(state.lastStartFailed) + } + + // A warm-up the user never asked for stays invisible - but it must not clear a + // failed-start tone on its way through, so the flag rides along. + is QuickBuildSessionState.Prebuilding -> { + // A warm build has no baseline to replace, so a tap that queues on one is + // always a session's first provision. + if (state.tapQueued) Provisioning() else Hidden(state.lastStartFailed) + } + + is QuickBuildSessionState.Provisioning -> { + Provisioning(state.rebaselineReason) + } + + is QuickBuildSessionState.Ready -> { + state.lastFailure?.let { Failed(state.generation, it) } + ?: UpToDate(state.generation, buildDurationMillis = null) + } + + is QuickBuildSessionState.Building -> { + when { + // A real build: the proxy app is one generation behind, say so. + !state.warmingCompiler -> Building(state.deployedGeneration) + + // A crash of the running generation surfaces immediately, exactly as it + // would outside the warm-compile window. + state.pendingCrash != null -> Failed(state.deployedGeneration, state.pendingCrash) + + // The warm compile recompiles what already runs and deploys nothing, + // so the app is genuinely up to date for its whole window. + else -> UpToDate(state.deployedGeneration, buildDurationMillis = null) + } + } + + is QuickBuildSessionState.Deployed -> { + UpToDate(state.generation, state.buildDurationMillis, state.restarted) + } + + is QuickBuildSessionState.Invalidated -> { + NeedsFullBuild(state.reason, state.deployedGeneration, state.awaitingRetry) + } + + is QuickBuildSessionState.Degraded -> { + Reconnecting(state.deployedGeneration, state.restartFailed) + } + } + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildTone.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildTone.kt new file mode 100644 index 0000000000..48d545fd3d --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildTone.kt @@ -0,0 +1,70 @@ +package org.appdevforall.cotg.quickbuild.domain.session + +/** + * Colorblind-safe presentation tone for the Quick Build toolbar icon. + * + * Status is never carried by color alone: each tone maps to a distinct icon shape as well as a + * distinct color. The app module owns that drawable/color mapping because it needs a Context; + * this type is the JVM-testable half. + * + * Only [ERROR] is colored as a failure - a tone the user cannot act on, or that resolves by itself + * (a full rebuild during ordinary editing, a daemon respawn), must not read as one. + */ +enum class QuickBuildTone { + /** Ready to build - no session, or a session sitting on a successful build. */ + READY, + + /** A build is running (provisioning or an active quick build). Tapping stops it. */ + BUILDING, + + /** The next build cannot take the fast path and will be a full one. Not a failure. */ + SLOW, + + /** The compile daemon is being respawned. Transient, resolves itself, nothing to do. */ + RECONNECTING, + + /** A failure the user has to deal with. */ + ERROR, +} + +/** + * Derives the toolbar tone from the status the session surface already exposes. + * + * @receiver the status currently rendered, so tone and status can never disagree. + * @return the tone for that status; [QuickBuildTone.READY] also covers a plain + * [QuickBuildStatus.Hidden], where the icon is present but no session is running. + */ +fun QuickBuildStatus.toTone(): QuickBuildTone = + when (this) { + // A failed START is a failure the user has to deal with - only a tap retries it - so + // it must not settle back to the green bolt the moment the failure flash fades. + is QuickBuildStatus.Hidden -> { + if (lastStartFailed) QuickBuildTone.ERROR else QuickBuildTone.READY + } + + is QuickBuildStatus.UpToDate -> { + QuickBuildTone.READY + } + + is QuickBuildStatus.Provisioning, + is QuickBuildStatus.Building, + -> { + QuickBuildTone.BUILDING + } + + // A rebaseline that failed and parked is not ordinary upcoming work: nothing moves + // until the user acts, which is exactly what ERROR means here. + is QuickBuildStatus.NeedsFullBuild -> { + if (awaitingRetry) QuickBuildTone.ERROR else QuickBuildTone.SLOW + } + + // A respawn that failed is not invisible work resolving itself: the compiler is down + // until the user taps, which is exactly what ERROR means here. + is QuickBuildStatus.Reconnecting -> { + if (restartFailed) QuickBuildTone.ERROR else QuickBuildTone.RECONNECTING + } + + is QuickBuildStatus.Failed -> { + QuickBuildTone.ERROR + } + } diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/README.md b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/README.md new file mode 100644 index 0000000000..6e221077b0 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/README.md @@ -0,0 +1,74 @@ +# `domain/session/` - the session state machine + +Pure-JVM state machine for a quick-build session: its states, the events that drive them, the effects the shell must run, and what the user is told. No Android. `SessionReducer.reduce` is total - an unhandled (state, event) pair keeps the state and emits no effects, so a late or duplicate event can never corrupt the session. `QuickBuildStatus` and `QuickBuildTone` derive purely from state, so a stuck banner or a wrong icon color is unrepresentable. + +| File | Purpose | +| --- | --- | +| [`SessionReducer.kt`](SessionReducer.kt) | The total transition function: maps (state, event) to next state plus ordered effects. | +| [`QuickBuildSessionState.kt`](QuickBuildSessionState.kt) | The state sealed type plus `SessionFailure`, `SessionEvent`, `SessionEffect`, and `SessionTransition`. | +| [`QuickBuildStatus.kt`](QuickBuildStatus.kt) | The status surface derived from state via `from(state)`. | +| [`QuickBuildTone.kt`](QuickBuildTone.kt) | The colorblind-safe toolbar tone derived from status via `toTone()`. | +| [`QuickBuildNotice.kt`](QuickBuildNotice.kt) | Enum of host-shown notices (named, not written, since this module has no `R`), each carrying its own tone. | +| [`QuickBuildMessage.kt`](QuickBuildMessage.kt) | Sealed type of named failure messages the host maps to string resources; `Literal` passes final text through. | + +## State machine + +This is the authoritative rendering: every transition with a guard, drawn in full. The copies in [quickbuild/README.md](../../../../../../../../../../README.md) and [docs/pipeline.md](../../../../../../../../../../docs/pipeline.md) are deliberately simplified for orientation. + +Arrows are labeled with the `SessionEvent` that drives them; parentheticals note the guard or a key effect. Self-loops that only run an effect (a tap that triggers a live reload, a retry that kicks off a rebuild) are shown; pure no-ops are not. + +```mermaid +stateDiagram-v2 + [*] --> Idle + + Idle --> Provisioning: QuickBuildTapped + Idle --> Prebuilding: PrebuildRequested + + Prebuilding --> Prebuilding: QuickBuildTapped (queue the tap) + Prebuilding --> Provisioning: PrebuildFinished (tap queued) + Prebuilding --> Idle: PrebuildFinished (no tap) + Prebuilding --> Idle: CancelRequested (tap queued) + + Provisioning --> Ready: ProvisioningSucceeded + Provisioning --> Idle: ProvisioningFailed + Provisioning --> Idle: CancelRequested + Provisioning --> Invalidated: ProxyAppRebuildInstallNotConfirmed + Provisioning --> Invalidated: ProxyAppRebuildDeferred + + Ready --> Ready: QuickBuildTapped (TriggerLiveReload) + Ready --> Building: BuildStarted + Ready --> Building: WarmCompileStarted + Ready --> Invalidated: InvalidationDetected + Ready --> Degraded: DaemonDied + Ready --> Ready: ProxyAppCrashed (record failure) + Ready --> Ready: ExternalBuildCompleted (RefreshBaseline) + + Building --> Deployed: BuildSucceeded + Building --> Ready: BuildFailed + Building --> Ready: CancelRequested (not warming) + Building --> Ready: WarmCompileFinished + Building --> Invalidated: InvalidationDetected + Building --> Degraded: DaemonDied + + Deployed --> Deployed: QuickBuildTapped (TriggerLiveReload) + Deployed --> Building: BuildStarted + Deployed --> Building: WarmCompileStarted + Deployed --> Invalidated: InvalidationDetected + Deployed --> Degraded: DaemonDied + Deployed --> Ready: ProxyAppCrashed (record failure) + Deployed --> Deployed: ExternalBuildCompleted (RefreshBaseline) + + Invalidated --> Provisioning: ProxyAppRebuildStarted + Invalidated --> Invalidated: QuickBuildTapped / HostForegrounded (RunProxyAppRebuild) + + Degraded --> Ready: DaemonRespawned + Degraded --> Invalidated: InvalidationDetected + Degraded --> Degraded: ExternalBuildCompleted (RefreshBaseline) + + note right of Idle + SessionRestartRequested from any + non-Idle state -> Idle (TeardownSession) + end note +``` + +The reducer is total: any (state, event) pair not drawn above keeps the current state and emits no effects. diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/SessionReducer.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/SessionReducer.kt new file mode 100644 index 0000000000..ea68d9473f --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/SessionReducer.kt @@ -0,0 +1,714 @@ +package org.appdevforall.cotg.quickbuild.domain.session + +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason + +/** + * Pure transition function for the session state machine. + * + * The reducer is total: an unknown (state, event) pair keeps the current state and produces no + * effects, so a late or duplicate event can never corrupt the session. The shell logs those. + */ +class SessionReducer { + /** + * Maps a state and an incoming event to the next state plus the effects to run. + * + * @param state the session's current state. + * @param event what happened; a state that does not handle it keeps [state] unchanged rather + * than failing. + * @return the state to adopt and the effects the shell must then run, in order. + */ + fun reduce( + state: QuickBuildSessionState, + event: SessionEvent, + ): SessionTransition { + // Restart always wins and always tears down, whatever state it came from, so it is + // handled once here rather than repeated in every per-state reducer. Idle has nothing + // to tear down and falls through to reduceIdle, which still clears a stale + // failed-start tone. + if (event == SessionEvent.SessionRestartRequested && state !is QuickBuildSessionState.Idle) { + return SessionTransition(QuickBuildSessionState.Idle(), listOf(SessionEffect.TeardownSession)) + } + // The user-facing restart, which also wins from any state. Unlike the teardown-only event + // above it never rests at Idle: it goes straight on to a fresh provision, so the toolbar + // icon turns BUILDING and the surfaces narrate the rebuild the user asked for. Idle has + // nothing to tear down, so it starts one without the teardown effect. + if (event == SessionEvent.SessionRestartAndReprovisionRequested) { + val effect = + if (state is QuickBuildSessionState.Idle) { + // Nothing to tear down, so this is an ordinary first provision. + SessionEffect.StartProvisioning + } else { + SessionEffect.TeardownAndProvision + } + return SessionTransition( + QuickBuildSessionState.Provisioning(userInitiated = true), + listOf(effect), + ) + } + return reduceByState(state, event) + } + + private fun reduceByState( + state: QuickBuildSessionState, + event: SessionEvent, + ): SessionTransition = + when (state) { + is QuickBuildSessionState.Idle -> reduceIdle(state, event) + is QuickBuildSessionState.Prebuilding -> reducePrebuilding(state, event) + is QuickBuildSessionState.Provisioning -> reduceProvisioning(state, event) + is QuickBuildSessionState.Ready -> reduceLive(state, state.generation, event) + is QuickBuildSessionState.Building -> reduceBuilding(state, event) + is QuickBuildSessionState.Deployed -> reduceLive(state, state.generation, event) + is QuickBuildSessionState.Invalidated -> reduceInvalidated(state, event) + is QuickBuildSessionState.Degraded -> reduceDegraded(state, event) + } + + private fun reduceIdle( + state: QuickBuildSessionState.Idle, + event: SessionEvent, + ): SessionTransition = + when (event) { + is SessionEvent.QuickBuildTapped -> { + SessionTransition( + QuickBuildSessionState.Provisioning(userInitiated = true), + listOf(SessionEffect.StartProvisioning), + ) + } + + SessionEvent.PrebuildRequested -> { + // The flag rides along so the silent warm build cannot clear a failed-start + // tone: only a tap or a save is a user gesture. + SessionTransition( + QuickBuildSessionState.Prebuilding(lastStartFailed = state.lastStartFailed), + listOf(SessionEffect.StartProxyAppPrebuild), + ) + } + + SessionEvent.FileSaved -> { + // The save is the clearing gesture, not a retry: no effect on purpose, so a + // save can never start a provision the user did not ask for. + if (state.lastStartFailed) { + SessionTransition(QuickBuildSessionState.Idle()) + } else { + SessionTransition(state) + } + } + + SessionEvent.SessionRestartRequested -> { + // Nothing to tear down, but an explicit teardown (project close, a Standard Run + // taking over the app id) ends the failed-start story too - the tone must not + // survive into whatever comes next. + if (state.lastStartFailed) { + SessionTransition(QuickBuildSessionState.Idle()) + } else { + SessionTransition(state) + } + } + + else -> { + SessionTransition(state) + } + } + + private fun reducePrebuilding( + state: QuickBuildSessionState.Prebuilding, + event: SessionEvent, + ): SessionTransition = + when (event) { + // The tap must not race the warm build (one Gradle build at a time through + // the tooling server); it queues and fires on PrebuildFinished. The tap is also + // the retry gesture, so it clears a carried failed-start tone. + is SessionEvent.QuickBuildTapped -> { + SessionTransition(state.copy(tapQueued = true, lastStartFailed = false)) + } + + SessionEvent.FileSaved -> { + // Same clearing gesture as in Idle; the warm build itself is not one. + if (state.lastStartFailed) { + SessionTransition(state.copy(lastStartFailed = false)) + } else { + SessionTransition(state) + } + } + + SessionEvent.PrebuildFinished -> { + if (state.tapQueued) { + SessionTransition( + QuickBuildSessionState.Provisioning(userInitiated = true), + listOf(SessionEffect.StartProvisioning), + ) + } else { + // A carried failed-start tone goes back to Idle uncleared: the warm build's + // outcome is silent either way, and only a tap or a save clears the tone. + SessionTransition(QuickBuildSessionState.Idle(lastStartFailed = state.lastStartFailed)) + } + } + + SessionEvent.CancelRequested -> { + if (state.tapQueued) { + // The button only shows the stop affordance once a tap has queued, so a + // cancel here means drop the queued tap AND stop the Gradle build it waits on. + SessionTransition(QuickBuildSessionState.Idle(), listOf(SessionEffect.CancelProxyAppBuild)) + } else { + SessionTransition(state) + } + } + + else -> { + SessionTransition(state) + } + } + + private fun reduceProvisioning( + state: QuickBuildSessionState.Provisioning, + event: SessionEvent, + ): SessionTransition = + when (event) { + is SessionEvent.ProvisioningSucceeded -> { + SessionTransition( + QuickBuildSessionState.Ready(event.generation), + // Behaviour 2: nothing else launches the freshly installed proxy app, so a + // tap gets its answer here. A rebuild routed through this state stays in + // the editor. + if (state.userInitiated) { + listOf(SessionEffect.StartWarmCompile, SessionEffect.SwitchToProxyApp) + } else { + listOf(SessionEffect.StartWarmCompile) + }, + ) + } + + SessionEvent.CancelRequested -> { + // No half-provisioned session is worth keeping. A cancel mid-install is safe + // because the epoch guard discards a late provisioning success, and the next + // tap re-provisions from build outputs still on disk. The user chose this, so + // the Idle it lands in carries no failure. + SessionTransition( + QuickBuildSessionState.Idle(), + listOf(SessionEffect.CancelProxyAppBuild, SessionEffect.TeardownSession), + ) + } + + is SessionEvent.ProvisioningFailed -> { + // lastStartFailed keeps the error tone on the bolt after the failure flash + // fades - a plain Idle here read READY right after a failed start (Q8). + SessionTransition( + QuickBuildSessionState.Idle(lastStartFailed = true), + listOf(SessionEffect.SurfaceProvisioningError(event.message)), + ) + } + + is SessionEvent.ProxyAppRebuildFailed -> { + // The user's build files do not build. The session itself is fine and the proxy app + // is still running, so park recoverable rather than die: the next save, a tap, or a + // return to CoGo retries. The auto-retry count is CARRIED, not reset - an unfixed + // build file must not buy a fresh budget of Gradle builds on every return. + // No effect on purpose: SurfaceProvisioningError tears the session down, which is + // the very thing being fixed here; the shell surfaces the reason before dispatching. + SessionTransition( + QuickBuildSessionState.Invalidated( + event.reason, + event.deployedGeneration, + awaitingRetry = true, + installAutoRetries = state.installAutoRetries, + ), + ) + } + + is SessionEvent.ProxyAppRebuildDeferred -> { + // Park back where the retry came from and refund the attempt: it ran no Gradle + // build and prompted no install, which is what the budget bounds. Floored at + // zero, since a tap-initiated retry arrives having already reset it. + SessionTransition( + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + event.deployedGeneration, + awaitingRetry = true, + installAutoRetries = (state.installAutoRetries - 1).coerceAtLeast(0), + ), + ) + } + + is SessionEvent.ProxyAppRebuildInstallNotConfirmed -> { + // Only the install confirmation is missing, so park with no effect - retrying + // here would re-prompt forever. The next tap or foreground return retries. The + // auto-retry count survives so the budget is spent per unconfirmed install, + // not per park. + SessionTransition( + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + event.deployedGeneration, + awaitingRetry = true, + installAutoRetries = state.installAutoRetries, + ), + ) + } + + else -> { + SessionTransition(state) + } + } + + /** + * Shared by [QuickBuildSessionState.Ready] and [QuickBuildSessionState.Deployed]. + * + * @param state the live state to return to when the event changes nothing. + * @param generation the generation the proxy app runs, passed separately because the two live + * states carry it under different property names. + * @param event what happened while the session was live. + * @return the state to adopt and the effects the shell must then run. + */ + private fun reduceLive( + state: QuickBuildSessionState, + generation: Long, + event: SessionEvent, + ): SessionTransition = + when (event) { + is SessionEvent.QuickBuildTapped -> { + SessionTransition( + state, + listOf( + SessionEffect.TriggerLiveReload( + userInitiated = true, + expectChanges = event.wroteSomething, + ), + ), + ) + } + + SessionEvent.BuildStarted -> { + SessionTransition(QuickBuildSessionState.Building(generation)) + } + + SessionEvent.WarmCompileStarted -> { + SessionTransition(QuickBuildSessionState.Building(generation, warmingCompiler = true)) + } + + is SessionEvent.InvalidationDetected -> { + SessionTransition( + QuickBuildSessionState.Invalidated(event.reason, generation), + listOf(SessionEffect.RunProxyAppRebuild), + ) + } + + SessionEvent.DaemonDied -> { + SessionTransition( + QuickBuildSessionState.Degraded(generation), + listOf(SessionEffect.RespawnDaemon), + ) + } + + is SessionEvent.ProxyAppCrashed -> { + SessionTransition( + QuickBuildSessionState.Ready(generation, SessionFailure.ProxyAppCrash(event.summary)), + ) + } + + SessionEvent.ExternalBuildCompleted -> { + SessionTransition(state, listOf(SessionEffect.RefreshBaseline)) + } + + else -> { + SessionTransition(state) + } + } + + private fun reduceBuilding( + state: QuickBuildSessionState.Building, + event: SessionEvent, + ): SessionTransition = + when (event) { + is SessionEvent.BuildSucceeded -> { + SessionTransition( + QuickBuildSessionState.Deployed(event.generation, event.durationMillis, event.restarted), + // Behaviour 2 vs 3: the deploy landing is where a TAP gets its answer, and + // where a save deliberately gets none - the user is still editing. + if (event.userInitiated) listOf(SessionEffect.SwitchToProxyApp) else emptyList(), + ) + } + + is SessionEvent.BuildFailed -> { + SessionTransition(QuickBuildSessionState.Ready(state.deployedGeneration, event.failure)) + } + + is SessionEvent.QuickBuildTapped -> { + if (state.warmingCompiler) { + // A warm compile deploys nothing, so the tap would otherwise vanish. The + // orchestrator answers it: a tap that wrote something builds off its own + // watcher batch right after the warm compile, and a clean tap switches + // without queueing a forced build. + SessionTransition( + state, + listOf( + SessionEffect.TriggerLiveReload( + userInitiated = true, + expectChanges = event.wroteSomething, + ), + ), + ) + } else { + // The in-flight build satisfies the tap's build but not the ask, so record + // the ask on it (behaviour 2) rather than dropping it. + SessionTransition(state, listOf(SessionEffect.MarkBuildUserInitiated)) + } + } + + SessionEvent.CancelRequested -> { + if (state.warmingCompiler) { + // The warm compile is not the user's build: unasked for, deploys nothing, + // and the button shows the bolt throughout. Nothing here to cancel. + SessionTransition(state) + } else { + // Behaviour 5: back to the generation the proxy app still runs, with no + // failure recorded - the user chose this, it is not an error. + SessionTransition( + QuickBuildSessionState.Ready(state.deployedGeneration), + listOf(SessionEffect.CancelLiveReload), + ) + } + } + + SessionEvent.WarmCompileFinished -> { + // The warm compile deployed nothing, so return to the unchanged generation. Its + // own outcome is not surfaced, but a crash of the running generation lands now. + SessionTransition(QuickBuildSessionState.Ready(state.deployedGeneration, state.pendingCrash)) + } + + is SessionEvent.InvalidationDetected -> { + SessionTransition( + QuickBuildSessionState.Invalidated(event.reason, state.deployedGeneration), + listOf(SessionEffect.RunProxyAppRebuild), + ) + } + + SessionEvent.DaemonDied -> { + SessionTransition( + QuickBuildSessionState.Degraded(state.deployedGeneration), + listOf(SessionEffect.RespawnDaemon), + ) + } + + is SessionEvent.ProxyAppCrashed -> { + if (state.warmingCompiler) { + // A warm compile ends in Ready with no failure, which would swallow this + // crash of the running generation - nothing is coming to supersede it. + // Carry it; WarmCompileFinished surfaces it. + SessionTransition(state.copy(pendingCrash = SessionFailure.ProxyAppCrash(event.summary))) + } else { + // The imminent deploy supersedes the crashed code, so stay Building. + SessionTransition(state) + } + } + + SessionEvent.ExternalBuildCompleted -> { + // The in-flight build may have read half-rewritten inputs; the baseline + // refresh coalesces into the follow-up build, which recompiles everything. + SessionTransition(state, listOf(SessionEffect.RefreshBaseline)) + } + + else -> { + SessionTransition(state) + } + } + + private fun reduceInvalidated( + state: QuickBuildSessionState.Invalidated, + event: SessionEvent, + ): SessionTransition = + when (event) { + SessionEvent.ProxyAppRebuildStarted -> { + // Deliberately not user-initiated even when a tap triggered the retry: a + // rebuild is a full Gradle build a save can also trigger, so finishing one is + // not by itself a reason to leave the editor. The auto-retry count is carried + // so an unconfirmed reinstall parks back with it intact, and the reason so the + // status surfaces can call this a rebaseline without having to have seen the + // Invalidated hop. + SessionTransition( + QuickBuildSessionState.Provisioning( + installAutoRetries = state.installAutoRetries, + rebaselineReason = state.reason, + ), + ) + } + + is SessionEvent.QuickBuildTapped -> { + if (state.awaitingRetry) { + // An explicit tap is fresh consent, so it re-arms the foreground auto-retry + // budget. awaitingRetry drops immediately so a second trigger arriving + // before ProxyAppRebuildStarted cannot double-run the Gradle build. + // + // The tap is still a request to see the app, so it is recorded rather than + // dropped - but a rebaseline holds the screen for a full Gradle build and an + // install only CoGo can confirm, so the shell holds the switch until the + // rebuild lands and abandons it if it does not. Answering it now would park + // the user in the app they already had for the whole build. + SessionTransition( + state.copy(awaitingRetry = false, installAutoRetries = 0), + listOf(SessionEffect.RunProxyAppRebuild, SessionEffect.SwitchToProxyApp), + ) + } else { + // A proxy app rebuild is already in flight; the trigger has nothing to add. + SessionTransition(state) + } + } + + is SessionEvent.InvalidationDetected -> { + if (state.awaitingRetry) { + // The user saved one of the files that parked us - overwhelmingly the fix for + // whatever failed. That save is the recovery gesture and has to move the + // session: a user who never leaves the editor sends neither a tap nor a + // foreground return, so nothing else would unpark it. The budget resets because + // a changed file is a genuinely new attempt, not a retry of the failure. + SessionTransition( + QuickBuildSessionState.Invalidated( + event.reason, + state.deployedGeneration, + awaitingRetry = false, + installAutoRetries = 0, + ), + listOf(SessionEffect.RunProxyAppRebuild), + ) + } else { + // A proxy app rebuild is already in flight; it will build from current disk. + SessionTransition(state) + } + } + + SessionEvent.BuildStarted -> { + if (state.awaitingRetry) { + // Parked with no rebuild in flight, so the orchestrator is holding nothing + // back (ProxyAppRebuildFailed cleared its absorption gate) and a save it + // judges absorbable really does start a quick build. That build has to be + // visible: without this hop the status stays on "a full build is needed" while + // builds run, deploy and fail unseen, which reads to the user as "I saved my + // fix and nothing happened". + SessionTransition(QuickBuildSessionState.Building(state.deployedGeneration)) + } else { + // A proxy app rebuild owns the session and is about to supersede this build, + // so its result is discarded by the orchestrator. Staying put is what keeps + // the ProxyAppRebuildStarted hop able to land. + SessionTransition(state) + } + } + + is SessionEvent.BuildSucceeded -> { + if (state.awaitingRetry) { + // The deploy landed, so the proxy app really does run the new generation; + // carrying on as Invalidated would keep reporting the old one. Reached + // without a BuildStarted of its own when the park and the build raced. + SessionTransition( + QuickBuildSessionState.Deployed(event.generation, event.durationMillis, event.restarted), + if (event.userInitiated) listOf(SessionEffect.SwitchToProxyApp) else emptyList(), + ) + } else { + // The rebuild that superseded this build is what the session waits on. + // Moving to Deployed here would leave ProxyAppRebuildStarted nowhere to land + // and narrate a multi-minute Gradle build as "up to date". + SessionTransition(state) + } + } + + is SessionEvent.BuildFailed -> { + if (state.awaitingRetry) { + // Same reachability as BuildSucceeded above. The failure has to be visible: + // a compile error is fixable in seconds, which is what Ready.lastFailure is + // for, and the next save re-reports the invalidation if the baseline is + // still stale. + SessionTransition(QuickBuildSessionState.Ready(state.deployedGeneration, event.failure)) + } else { + SessionTransition(state) + } + } + + SessionEvent.DaemonDied -> { + if (state.awaitingRetry) { + // Deliberately stays Invalidated - the stale baseline is the more urgent + // fact and only Gradle clears it - but the compiler still has to come back, + // or every later save's quick build dies on a dead daemon and the session + // never moves again. + SessionTransition(state, listOf(SessionEffect.RespawnDaemon)) + } else { + // A proxy app rebuild is in flight and restarts the daemon itself (see + // ProxyAppBuildRunner's DaemonRestartFailed outcome); a respawn issued here + // would race it for the same daemon. + SessionTransition(state) + } + } + + SessionEvent.DaemonRespawned -> { + // Deliberately ignored: a working compiler does not make a stale baseline + // fresh, so the park stands until a full Gradle build clears it. + SessionTransition(state) + } + + SessionEvent.WarmCompileStarted, + SessionEvent.WarmCompileFinished, + -> { + // Deliberately ignored: a warm compile deploys nothing and its outcome is never + // surfaced, so routing it through Building would end in Ready and silently + // cancel the park - losing both the reason and the retry. + SessionTransition(state) + } + + is SessionEvent.ProxyAppCrashed -> { + // Deliberately ignored, and not silent: the manager flashes + // QuickBuildNotice.RELOAD_CRASHED on every crash before dispatching this, so + // the user is told. All the state decides is the STATUS, and "a full build is + // needed" outranks a crash that already rolled back to the generation the proxy + // app is still running. + SessionTransition(state) + } + + SessionEvent.HostForegrounded -> { + if (state.awaitingRetry && state.installAutoRetries < MAX_INSTALL_AUTO_RETRIES) { + // The user's return is the first chance to re-prompt an install dialog that + // was never launched (see HostForegrounded). awaitingRetry drops immediately + // so a second trigger arriving before ProxyAppRebuildStarted cannot + // double-run the Gradle build. + SessionTransition( + state.copy(awaitingRetry = false, installAutoRetries = state.installAutoRetries + 1), + listOf(SessionEffect.RunProxyAppRebuild), + ) + } else { + // Proxy app rebuild in flight, or the auto-retry budget is spent: stay parked. + SessionTransition(state) + } + } + + else -> { + // What legitimately reaches here: the prebuild and provisioning events, which + // belong to phases with no live session; CancelRequested, since the button offers + // no stop affordance while a full build is what is needed; and the + // ProxyAppRebuild* outcomes, which are dispatched from Provisioning, after the + // ProxyAppRebuildStarted hop moved the session there. + SessionTransition(state) + } + } + + private fun reduceDegraded( + state: QuickBuildSessionState.Degraded, + event: SessionEvent, + ): SessionTransition = + when (event) { + SessionEvent.DaemonRespawned -> { + if (state.restartFailed) { + // The daemon this announces has already been reported dead - the respawned + // child died in the window between start() returning Ok and this landing. Going + // Ready here would claim a live compiler and hide the outage until the next + // save discovered it; stay degraded and keep telling the truth. + SessionTransition(state) + } else { + SessionTransition(QuickBuildSessionState.Ready(state.deployedGeneration)) + } + } + + SessionEvent.DaemonDied -> { + // Deliberately schedules no second respawn: the one already attempted either failed + // or produced a daemon that died immediately, and auto-retrying a hard-broken + // compiler just spins. What it must do is stop the status claiming a restart is in + // flight. The two gestures that recover from here are a Quick Build tap (below) and + // a save, whose build dies on the dead daemon and arrives as DaemonDied from + // Building, which does respawn. + SessionTransition(state.copy(restartFailed = true)) + } + + SessionEvent.DaemonRestartFailed -> { + SessionTransition(state.copy(restartFailed = true)) + } + + is SessionEvent.QuickBuildTapped -> { + // The one gesture the user has while the compiler is down, so it must not fall through + // to the else below - that would answer the tap with no build, no message and no Build + // Output line, since that pane is driven by status transitions. A failed respawn leaves + // the daemon epoch alone, so the retry really runs; the message goes out alongside it + // because a respawn still in flight answers with Superseded and would otherwise leave + // the tap unacknowledged. Clearing restartFailed makes the status honest again. + SessionTransition( + state.copy(restartFailed = false), + listOf( + SessionEffect.SurfaceMessage(QuickBuildMessage.DaemonRestartRetrying), + SessionEffect.RespawnDaemon, + ), + ) + } + + SessionEvent.BuildStarted -> { + // The watcher never stops, so a save while the compiler is down still starts a quick + // build, and this hop is what makes it visible - without it the status stays on + // "restarting the compiler" while save after save comes to nothing. A build that then + // dies on the dead daemon arrives as DaemonDied from Building, which respawns again, + // so each save both narrates itself and pushes recovery along. + SessionTransition(QuickBuildSessionState.Building(state.deployedGeneration)) + } + + is SessionEvent.BuildSucceeded -> { + // Reachable with no BuildStarted of its own: the daemon death listener can fire + // mid-build, parking the session here while that build runs on. A deploy that landed + // moved the proxy app, whatever the daemon did afterwards. + SessionTransition( + QuickBuildSessionState.Deployed(event.generation, event.durationMillis, event.restarted), + if (event.userInitiated) listOf(SessionEffect.SwitchToProxyApp) else emptyList(), + ) + } + + is SessionEvent.BuildFailed -> { + // Same reachability as BuildSucceeded above. A build that reported diagnostics reached + // a working compiler, so Ready is honest and the diagnostics are what the user needs; + // a daemon death arrives as DaemonDied instead, never here. + SessionTransition(QuickBuildSessionState.Ready(state.deployedGeneration, event.failure)) + } + + SessionEvent.WarmCompileStarted, + SessionEvent.WarmCompileFinished, + -> { + // Deliberately ignored: a warm compile deploys nothing and its outcome is never + // surfaced, so routing it through Building would swap "restarting the compiler" for + // "up to date" while the daemon is still being respawned. + SessionTransition(state) + } + + is SessionEvent.ProxyAppCrashed -> { + // Deliberately ignored, and not silent: the manager flashes + // QuickBuildNotice.RELOAD_CRASHED on every crash before dispatching this. All the + // state decides is the STATUS, and "restarting the compiler" outranks a crash that + // already rolled back to the generation the proxy app is still running. + SessionTransition(state) + } + + is SessionEvent.InvalidationDetected -> { + // The orchestrator reports an invalidation once, so dropping this would strand + // the session: a gradle/manifest edit landing while Degraded would never + // rebuild and no build would run again. The rebuild needs Gradle rather than + // the daemon, and the shell's daemonEpoch guard keeps it from racing the + // in-flight respawn. + SessionTransition( + QuickBuildSessionState.Invalidated(event.reason, state.deployedGeneration), + listOf(SessionEffect.RunProxyAppRebuild), + ) + } + + SessionEvent.ExternalBuildCompleted -> { + SessionTransition(state, listOf(SessionEffect.RefreshBaseline)) + } + + else -> { + // What legitimately reaches here: the prebuild and provisioning events, which + // belong to phases with no live session; CancelRequested, since a save's build only + // becomes cancellable once BuildStarted has moved the session to Building; the + // ProxyAppRebuild* outcomes, dispatched from Provisioning; and HostForegrounded, + // which only a parked Invalidated acts on. + SessionTransition(state) + } + } + + companion object { + /** + * How many times [SessionEvent.HostForegrounded] may auto-retry an unconfirmed reinstall + * before the session stays parked. + * + * Each retry costs a full Gradle build plus an install prompt, so two declined prompts is + * taken as "not now"; after that only an explicit tap re-prompts and re-arms the budget. + */ + const val MAX_INSTALL_AUTO_RETRIES = 2 + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppBuildRunner.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppBuildRunner.kt new file mode 100644 index 0000000000..f00e1dea30 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppBuildRunner.kt @@ -0,0 +1,460 @@ +package org.appdevforall.cotg.quickbuild.service.provision + +import org.appdevforall.cotg.quickbuild.data.DaemonReply +import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo +import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout +import org.appdevforall.cotg.quickbuild.data.QuickBuildScratch +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind +import org.appdevforall.cotg.quickbuild.domain.reload.GenerationStore +import org.appdevforall.cotg.quickbuild.domain.reload.GenerationTracker +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink +import org.appdevforall.cotg.quickbuild.service.deploy.DeploySender +import org.appdevforall.cotg.quickbuild.service.deploy.ProxyAppConnections +import org.appdevforall.cotg.quickbuild.service.session.LiveReloadExecutorImpl +import org.appdevforall.cotg.quickbuild.service.session.LiveSession +import org.appdevforall.cotg.quickbuild.service.session.LiveSessionFactory +import org.appdevforall.cotg.quickbuild.service.session.QuickBuildDaemonController +import org.appdevforall.cotg.quickbuild.service.telemetry.report +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Runs the Gradle proxy app builds - the first provision and the full-rebuild fallback - + * and returns what happened as a verdict. + * + * Owns no state: it never reads the live session, touches the session epoch, or dispatches - + * the manager does all of that with the returned result. Each call takes a `superseded` + * closure, the manager's epoch check, probed at the points that can be raced without the + * runner ever seeing the epoch. Call only on the session dispatcher. + */ +internal class ProxyAppBuildRunner( + /** The door to Gradle; contractually never throws, though this class still guards it. */ + private val provisioner: QuickBuildProvisioner, + /** Daemon lifecycle; every transition here is marked intentional before it runs. */ + private val daemonController: QuickBuildDaemonController, + /** Deploy-channel registry, opened to the proxy app's uid once its install is confirmed. */ + private val connections: ProxyAppConnections, + /** Deploy channel, used only to observe the rebuild relaunch's runtime reconnect. */ + private val deploy: DeploySender, + /** Relaunches the reinstalled proxy app; the same launcher the restart deploy uses. */ + private val launcher: ProxyAppLauncher, + /** App-private scratch trees: disk-space guard plus the per-project tree. */ + private val scratch: QuickBuildScratch, + /** Assembles the session once every prerequisite is up. */ + private val sessionFactory: LiveSessionFactory, + /** Opens the project's persisted generation counter, keyed by its root directory. */ + private val generationStoreFactory: (File) -> GenerationStore, + /** Analytics port; only the rebuild path books to it, and only through [report]. */ + private val metrics: QuickBuildMetricsSink, + /** + * How long the relaunched app gets to boot, bind, and report its generation. The same + * bound the restart-deploy relaunch waits, so the two paths share their latency + * characteristics. + */ + private val restartReconnectTimeoutMillis: Long = + LiveReloadExecutorImpl.DEFAULT_RESTART_RECONNECT_TIMEOUT_MILLIS, +) { + /** What became of a [provision]. The manager dispatches on it; this class does not. */ + sealed interface ProvisionResult { + /** + * The private volume was short before anything ran, so there is nothing to undo. + * + * @property message names the shortfall, and is shown to the user verbatim + */ + data class DiskSpaceShort( + val message: QuickBuildMessage, + ) : ProvisionResult + + /** + * Provisioning failed somewhere it could not recover from; any side effect it began + * has already been unwound. + * + * @property message user-facing failure text + */ + data class Failed( + val message: QuickBuildMessage, + ) : ProvisionResult + + /** Outlived a session restart before any side effect went live; discard silently. */ + data object Superseded : ProvisionResult + + /** + * Outlived a session restart while the daemon start was in flight. + * + * The runner already ended the connection session it began. The manager must bump + * the daemon epoch and stop the zombie daemon on a fresh coroutine, since this one + * is already cancelled by the teardown that superseded it. + */ + data object SupersededDuringDaemonStart : ProvisionResult + + /** + * Everything is up; the manager installs the session and goes live. + * + * @property session assembled but inert - its watcher is not started yet + * @property tracker the same allocator the session holds, handed over so the + * manager can publish the current generation without reaching into the session + */ + data class Succeeded( + val session: LiveSession, + val tracker: GenerationTracker, + /** + * The generation stamped into the installed baseline APK, which the app boots + * at; 0 for an unstamped build. The manager adopts it as the session's deployed + * generation. + */ + val baselineGeneration: Long, + ) : ProvisionResult + } + + /** + * Runs the one-time provision: disk-space guard, Gradle proxy app build and install, + * scratch tree, deploy-channel session, daemon start, and session assembly. + * + * @param superseded probed after the Gradle build and after the daemon start, the two + * points a "Restart session" can land + * @return what happened; the two superseded results differ in what the manager must + * still clean up, so they must not be collapsed + */ + suspend fun provision(superseded: () -> Boolean): ProvisionResult { + // Fail in seconds with a clear message rather than let a full private volume + // ENOSPC minutes into the proxy app build or mid-quick-build. + scratch.freeSpaceShortfall()?.let { message -> + return ProvisionResult.DiskSpaceShort(message) + } + + val outcome = + try { + provisioner.provision() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Throwable) { + log.error("Provisioner threw instead of reporting an outcome", e) + ProvisionOutcome.Failure(QuickBuildMessage.Literal(e.message ?: e.javaClass.name)) + } + + if (superseded()) { + // "Restart session" landed while the proxy app build ran. The user asked for + // a fresh start, so a late success must not resurrect and a late failure must + // not surface. + return ProvisionResult.Superseded + } + + return when (outcome) { + is ProvisionOutcome.Failure -> { + ProvisionResult.Failed(outcome.message) + } + + is ProvisionOutcome.Success -> { + // Scratch tree on app-private storage: the executor and daemon dirs below + // live here, never under the FUSE-backed project root. + when (val prepared = scratch.prepare(outcome.layout.projectRoot)) { + is QuickBuildScratch.Preparation.Failed -> { + return ProvisionResult.Failed(prepared.message) + } + + is QuickBuildScratch.Preparation.Ready -> { + Unit + } + } + + // Error boundary over the whole session assembly: a throw past this point + // would escape to a session scope with no CoroutineExceptionHandler and + // crash CoGo with a uid session already registered. + var sessionBegun = false + var daemonStarted = false + try { + connections.beginSession(outcome.proxyApp.proxyAppPackage, outcome.proxyAppUid) + sessionBegun = true + + daemonController.markIntentionalTransition() + when (val started = daemonController.start(outcome.layout, outcome.proxyApp)) { + is DaemonReply.Ok -> { + daemonStarted = true + if (superseded()) { + // Restart raced the daemon start: undo what began here; the + // manager stops the zombie daemon. + connections.endSession() + return ProvisionResult.SupersededDuringDaemonStart + } + val tracker = + GenerationTracker(generationStoreFactory(outcome.layout.projectRoot)) + ProvisionResult.Succeeded( + sessionFactory.create(outcome, tracker), + tracker, + outcome.baselineGeneration, + ) + } + + is DaemonReply.BuildFailed -> { + ProvisionResult.Failed(QuickBuildMessage.DaemonRejectedConfiguration) + } + + is DaemonReply.Failed -> { + ProvisionResult.Failed(QuickBuildMessage.Literal(started.message)) + } + } + } catch (e: kotlinx.coroutines.CancellationException) { + // A real teardown superseded this provision; its epoch bump already ran + // (or is about to run) endSession + shutdown for us. + throw e + } catch (e: Throwable) { + log.error("Session assembly threw after the proxy app build; unwinding", e) + if (sessionBegun) connections.endSession() + if (daemonStarted) { + // Same intentional-transition mark the teardown path uses, so the + // death listener never respawns a daemon shut down on purpose. + daemonController.markIntentionalTransition() + daemonController.shutdown() + } + ProvisionResult.Failed(QuickBuildMessage.Literal(e.message ?: e.javaClass.name)) + } + } + } + } + + /** What became of a [rebuildProxyApp]. */ + sealed interface ProxyAppRebuildResult { + /** The Gradle slot was taken so nothing ran. The manager decides park versus fail. */ + data object BuildSlotBusy : ProxyAppRebuildResult + + /** Outlived a session restart; the manager discards without touching the session. */ + data object Superseded : ProxyAppRebuildResult + + /** + * The rebuild failed. The daemon stays down, so the session cannot quick-build + * until a later attempt succeeds. + * + * @property message user-facing failure text + */ + data class Failed( + val message: QuickBuildMessage, + ) : ProxyAppRebuildResult + + /** + * The Gradle build was fine; only the reinstall confirmation is missing. + * + * @property message case-specific text telling the user how to re-prompt + */ + data class InstallNotConfirmed( + val message: QuickBuildMessage, + ) : ProxyAppRebuildResult + + /** + * The rebuild succeeded but the daemon refused to come back up on the new config. + * + * @property message the daemon's own failure text, or a generic rejection note + */ + data class DaemonRestartFailed( + val message: String, + ) : ProxyAppRebuildResult + + /** + * Rebuilt, reinstalled, the daemon restarted against the new setup's config, and + * the reinstalled app relaunched (best-effort; a relaunch failure is reported + * through the rebuild metric, not here). The manager moves the live session's + * ProxyAppInfo-derived pieces to this baseline. + */ + data class Succeeded( + /** The re-read report, not the provisioning-time snapshot. */ + val proxyApp: ProxyAppInfo, + /** Derived from the same re-read report as [proxyApp]. */ + val layout: QuickBuildProjectLayout, + /** + * The generation stamped into the reinstalled baseline APK; 0 for an unstamped + * build. The manager moves the session's deployed generation to it. + */ + val baselineGeneration: Long, + ) : ProxyAppRebuildResult + } + + /** + * Runs the full-Gradle proxy app rebuild: daemon teardown, Gradle build and + * reinstall, then on success the daemon restart against the new setup's config and + * the relaunch of the reinstalled app, then the rebuild metric. + * + * The relaunch is best-effort by contract: the reinstall killed the app's process, so + * without it every rebaseline strands the user in front of a dead app, but a relaunch + * failure never fails the rebuild - the new baseline is installed and the daemon is up. + * It only reports honestly through the metric's relaunch fields. + * + * @param parkedRetry true when this retries an unconfirmed reinstall from the parked state, + * in which case a [ProxyAppRebuildResult.BuildSlotBusy] books no metric because the build + * never ran (a first rebuild losing the slot does surface as a failure, so it books like + * one). + * @param superseded the manager's epoch check, probed once the Gradle build is done + * @return what happened; the daemon is left down for every result except a success + */ + suspend fun rebuildProxyApp( + parkedRetry: Boolean, + superseded: () -> Boolean, + ): ProxyAppRebuildResult { + // Free the daemon's memory for the Gradle build about to peak; on a 3-4GB device + // the two must not coexist. Nothing is lost: the daemon's incremental state is + // stale after a rebuild anyway, and on success it restarts below against the new + // config - a surviving daemon would keep serving the old configure's classpath. + daemonController.markIntentionalTransition() + daemonController.shutdown() + + val startedAtNanos = System.nanoTime() + val outcome = + try { + provisioner.rebuildProxyApp() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Throwable) { + log.error("Proxy app rebuild threw instead of reporting an outcome", e) + ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal(e.message ?: e.javaClass.name)) + } + // Captured here so the relaunch below cannot leak into the build cost: + // durationMillis is the Gradle wall clock, and existing consumers parse it as such. + val buildMillis = (System.nanoTime() - startedAtNanos) / 1_000_000 + val isSuccess = outcome is ProxyAppRebuildOutcome.Success + // Only a deferred retry that lost the slot skips metrics; see [parkedRetry]. + val bookMetric = outcome !is ProxyAppRebuildOutcome.BuildSlotBusy || !parkedRetry + + // Booked once per attempt, on every branch below, but only once the relaunch's + // outcome is known: a failed or skipped relaunch must never share field values + // with a relaunch that came back running. + fun bookRebuildMetric( + relaunchOk: Boolean, + toRunningMillis: Long?, + ) { + if (!bookMetric) return + report { + metrics.onProxyAppRebuild( + isSuccess = isSuccess, + durationMillis = buildMillis, + relaunchOk = relaunchOk, + toRunningMillis = toRunningMillis, + ) + } + } + + if (superseded()) { + bookRebuildMetric(relaunchOk = false, toRunningMillis = null) + return ProxyAppRebuildResult.Superseded + } + + return when (outcome) { + is ProxyAppRebuildOutcome.BuildSlotBusy -> { + bookRebuildMetric(relaunchOk = false, toRunningMillis = null) + ProxyAppRebuildResult.BuildSlotBusy + } + + is ProxyAppRebuildOutcome.Failure -> { + bookRebuildMetric(relaunchOk = false, toRunningMillis = null) + ProxyAppRebuildResult.Failed(outcome.message) + } + + is ProxyAppRebuildOutcome.InstallNotConfirmed -> { + bookRebuildMetric(relaunchOk = false, toRunningMillis = null) + ProxyAppRebuildResult.InstallNotConfirmed(outcome.message) + } + + is ProxyAppRebuildOutcome.Success -> { + // Restart the daemon torn down above, against the new proxy app's config. + daemonController.markIntentionalTransition() + when (val started = daemonController.start(outcome.layout, outcome.proxyApp)) { + is DaemonReply.Ok -> { + val toRunningMillis = relaunchRebuiltProxyApp(outcome.proxyApp, startedAtNanos) + bookRebuildMetric( + relaunchOk = toRunningMillis != null, + toRunningMillis = toRunningMillis, + ) + ProxyAppRebuildResult.Succeeded( + outcome.proxyApp, + outcome.layout, + outcome.baselineGeneration, + ) + } + + else -> { + // No relaunch: the loop is broken until the session recovers, and a + // live app next to a failure banner would read as the rebuild + // having worked. + bookRebuildMetric(relaunchOk = false, toRunningMillis = null) + ProxyAppRebuildResult.DaemonRestartFailed( + (started as? DaemonReply.Failed)?.message ?: "daemon rejected configuration", + ) + } + } + } + } + } + + /** + * Relaunches the just-reinstalled proxy app and waits for its runtime to reconnect. + * + * The same machinery as the restart deploy's relaunch: the proxied launcher activity + * when one carries MAIN/LAUNCHER, else null so the launcher falls back to the package's + * default launch intent, which resolves an `` launcher. Exactly two + * attempts, because a start can be silently swallowed by the task the killed process + * left behind and a second one then lands - two, not a loop, so a genuinely dead app + * surfaces instead of becoming a retry storm. + * + * @param proxyApp the re-read baseline just reinstalled; supplies the relaunch target + * @param rebuildStartedAtNanos when [rebuildProxyApp] started, so the returned span runs + * rebuild start to "app loaded and starting to run" - the endpoint the deploy paths + * measure to + * @return millis from rebuild start to the runtime's reconnect, or null when the app + * never came back + */ + private suspend fun relaunchRebuiltProxyApp( + proxyApp: ProxyAppInfo, + rebuildStartedAtNanos: Long, + ): Long? { + val launcherActivity = + proxyApp.components + .firstOrNull { it.kind == ComponentKind.ACTIVITY && it.launcher } + ?.proxyClass + if (!launcher.launch(proxyApp.proxyAppPackage, launcherActivity)) { + log.warn( + "Proxy app {} could not be relaunched after the rebuild; open it manually", + proxyApp.proxyAppPackage, + ) + return null + } + var reconnectGeneration = deploy.awaitReconnect(restartReconnectTimeoutMillis) + if (reconnectGeneration == null) { + // A launch can be swallowed rather than refused: an intent aimed at the task + // the killed process left behind is dropped with that task, and the second + // intent finds no task and creates one (see the restart deploy's retry). + log.info( + "Proxy app {} did not come back after the rebuild relaunch; launching it once more", + proxyApp.proxyAppPackage, + ) + if (launcher.launch(proxyApp.proxyAppPackage, launcherActivity)) { + reconnectGeneration = deploy.awaitReconnect(restartReconnectTimeoutMillis) + } + } + if (reconnectGeneration == null) { + log.warn( + "Proxy app {} did not reconnect after the rebuild relaunch; open it manually", + proxyApp.proxyAppPackage, + ) + return null + } + return (System.nanoTime() - rebuildStartedAtNanos) / 1_000_000 + } + + /** + * True when the proxy app build artifacts the daemon compiles against are still on + * disk. + * + * Used on hand-back: an external clean that wiped `build/` forces a rebuild, while + * anything less only needs a baseline refresh. + * + * @param proxyApp the baseline to check; its optional paths count as intact when the + * baseline never had them, so a pre-v2 setup is not mistaken for a wiped one + * @return true when every artifact the daemon compiles against is still present + */ + fun proxyAppArtifactsIntact(proxyApp: ProxyAppInfo): Boolean = + proxyApp.classpath.all { it.exists() } && + proxyApp.proxyClassesDir?.isDirectory != false && + proxyApp.transformedManifest?.isFile != false + + private companion object { + private val log = LoggerFactory.getLogger("QB-ProxyBuildRunner") + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImpl.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImpl.kt new file mode 100644 index 0000000000..7fe0eff6f3 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImpl.kt @@ -0,0 +1,537 @@ +package org.appdevforall.cotg.quickbuild.service.session + +import kotlinx.coroutines.CancellationException +import org.appdevforall.cotg.quickbuild.data.AssetPackager +import org.appdevforall.cotg.quickbuild.data.DaemonReply +import org.appdevforall.cotg.quickbuild.data.QuickBuildDaemon +import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout +import org.appdevforall.cotg.quickbuild.data.RelinkInputs +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.reload.BuildRequest +import org.appdevforall.cotg.quickbuild.domain.reload.DeployDecision +import org.appdevforall.cotg.quickbuild.domain.reload.DeployPolicy +import org.appdevforall.cotg.quickbuild.domain.reload.GenerationTracker +import org.appdevforall.cotg.quickbuild.domain.reload.LiveReloadExecutor +import org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink +import org.appdevforall.cotg.quickbuild.service.deploy.BuildStatusJson +import org.appdevforall.cotg.quickbuild.service.deploy.DeploySender +import org.appdevforall.cotg.quickbuild.service.deploy.PayloadDeployer +import org.appdevforall.cotg.quickbuild.service.deploy.RetainedPayloadStore +import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppLauncher +import org.appdevforall.cotg.quickbuild.service.telemetry.E2eTimelineRecorder +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Turns one classified changed-set into new code running in the proxy app. + * + * Every failure becomes a [BuildOutcome] rather than escaping, and a generation is allocated + * only once the build steps succeed, so a compile error burns none. After a successful compile + * [deployPolicy] picks hot swap or process restart - a recompiled service, provider, or + * Application class cannot be swapped into a live instance - and [PayloadDeployer] owns the rest. + */ +class LiveReloadExecutorImpl( + /** Warm compile/dex/relink server; a death mid-build surfaces as a daemon-died outcome. */ + private val daemon: QuickBuildDaemon, + /** Binder channel to the running proxy app; also carries the build-status notifications. */ + private val deploy: DeploySender, + /** Source, resource, and manifest roots of the user's module, re-read on every build. */ + private val layout: QuickBuildProjectLayout, + /** The user app's entry activity FQN, echoed to the runtime in payload metadata. */ + private val entryActivity: String, + /** Allocates generations; only a build that reaches deploy is allowed to burn one. */ + private val generations: GenerationTracker, + /** Scratch dir for payload staging (the changed-assets zip). */ + private val workDir: File, + /** + * The proxy app build's proxy classes, bundled into every payload dex. The manifest's + * proxy components extend user classes, so a payload without them cannot be loaded. + */ + private val proxyClassesDir: File? = null, + /** The proxy app build's transformed manifest; relinks link against it when present. */ + private val proxyAppManifest: File? = null, + /** Restart-vs-recreate decision. Null, for a session without one, always hot-swaps. */ + private val deployPolicy: DeployPolicy? = null, + /** The installed proxy app's applicationId; restart relaunch target. */ + private val proxyAppPackage: String? = null, + /** + * Launcher proxy activity FQN from the transformed manifest, the restart relaunch + * target. Null when the MAIN/LAUNCHER filter sits on an `` that no + * proxied activity carries; the relaunch then uses the package's default launch + * intent. + */ + private val launcherActivity: String? = null, + /** Relaunches the proxy app on the restart path. Null makes a restart deploy fail honestly. */ + private val launcher: ProxyAppLauncher? = null, + /** How long a relaunched proxy app gets to boot, bind, and report its generation. */ + private val restartReconnectTimeoutMillis: Long = DEFAULT_RESTART_RECONNECT_TIMEOUT_MILLIS, + /** Monotonic clock for the e2e timeline; must be the one the orchestrator stamps t0 with. */ + private val clock: () -> Long = System::currentTimeMillis, + /** + * Analytics channel for the per-generation timeline. The app wires the Firebase-backed + * sink; the default no-op keeps existing callers and tests unchanged. + */ + private val metrics: QuickBuildMetricsSink = QuickBuildMetricsSink.Noop, +) : LiveReloadExecutor { + /** Builds the changed-assets zip. */ + private val assetPackager = AssetPackager() + + /** + * Last-deployed retention for the reconnect re-send, keyed off [workDir] so the session + * manager reads the same location this executor writes (see [RetainedPayloadStore.forWorkDir]). + */ + private val retention = RetainedPayloadStore.forWorkDir(workDir) + + /** + * Whether the build now running answers a Quick Build tap, which is what lets its deploy + * bring the proxy app forward. Seeded from each request and raised in place by + * [markCurrentBuildUserInitiated], so a tap that lands mid-build still counts. Volatile + * because the promotion arrives on the orchestrator's lock, not the build's coroutine. + */ + @Volatile private var currentBuildUserInitiated = false + + private val payloadDeployer = + PayloadDeployer( + deploy = deploy, + generations = generations, + entryActivity = entryActivity, + proxyAppPackage = proxyAppPackage, + launcherActivity = launcherActivity, + launcher = launcher, + restartDisconnectTimeoutMillis = DEFAULT_RESTART_DISCONNECT_TIMEOUT_MILLIS, + restartReconnectTimeoutMillis = restartReconnectTimeoutMillis, + clock = clock, + reportTimeline = ::reportTimeline, + userInitiated = { currentBuildUserInitiated }, + retention = retention, + ) + + override fun markCurrentBuildUserInitiated() { + currentBuildUserInitiated = true + } + + override suspend fun execute(request: BuildRequest): BuildOutcome = + try { + currentBuildUserInitiated = request.userInitiated + val outcome = executeInner(request) + // A warm compile recompiles what the proxy app already runs and deploys + // nothing, so flashing build-ok or build-failed on its overlay would announce + // a build the user never triggered. The outcome still flows to the + // orchestrator for recovery routing. + if (request.route !is BuildRoute.WarmCompile) notifyProxyApp(outcome) + outcome + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + log.error("Quick build #{} pipeline failure", request.buildId, e) + BuildOutcome.InfrastructureFailure(e.message ?: e.javaClass.name) + } + + /** + * Tells the proxy app about a build that shipped no payload, so it never runs old + * code with nothing on screen to say why. + * + * A compile error shows; a success clears a previously shown failure. Best-effort by + * contract. + * + * @param outcome the build's verdict; only compile errors and successes say anything, + * because every other outcome already has a surface of its own + */ + private fun notifyProxyApp(outcome: BuildOutcome) { + try { + when (outcome) { + is BuildOutcome.CompileError -> { + deploy.notifyBuildStatus(BuildStatusJson.buildFailed(outcome.diagnostics)) + } + + is BuildOutcome.Success -> { + deploy.notifyBuildStatus(BuildStatusJson.buildOk()) + } + + // Deploy and infrastructure failures surface in CoGo's own status UI, + // and a RequiresProxyAppRebuild goes through the session's fallback + // flow, so the proxy app has nothing to add. + else -> { + Unit + } + } + } catch (e: Exception) { + // Best-effort messaging must never rewrite a real outcome: a throw here + // would turn a CompileError into an InfrastructureFailure upstream. + log.warn("Build-status notification failed", e) + } + } + + /** + * Hands a completed timeline to the log line and the analytics sink. + * + * The log line is one structured [E2eTimeline.format] line at INFO, which is what the + * benchmark harness parses. The metrics call is guarded so a misbehaving sink degrades to + * a warning rather than failing a build the user already saw reload. + * + * @param timeline the finished timeline; reported only for a build that actually went + * live, so a failed build never emits a line the harness would parse + */ + private fun reportTimeline(timeline: E2eTimeline) { + log.info(timeline.format()) + try { + metrics.onReloadTimeline(timeline) + } catch (e: Throwable) { + log.warn("Quick Build reload-timing metric failed", e) + } + } + + /** + * Runs one build request down its route; [execute] adds the error boundary. + * + * @param request the classified build; its route selects the pipeline and its + * `forced` flag is what makes an empty change-set still deploy + * @return the outcome for the orchestrator; may throw, which is why [execute] wraps it. + */ + private suspend fun executeInner(request: BuildRequest): BuildOutcome { + val startedAt = clock() + val timeline = E2eTimelineRecorder(request.triggeredAtMillis) { daemon.scratchFsType } + // The reported duration is the whole save-to-live loop, measured from t0 - the same span + // the timeline totals, so the two never disagree. A stamp of 0 means the caller has no + // clock (see BuildRequest.triggeredAtMillis): there is then no t0, so fall back to this + // build's own start rather than measuring from the epoch, and report no queue at all. + val triggeredAt = request.triggeredAtMillis + val loopStartedAt = if (triggeredAt > 0) triggeredAt else startedAt + if (triggeredAt > 0) timeline.recordQueue(startedAt - triggeredAt) + + if (request.route is BuildRoute.WarmCompile) { + // Compile and dex everything once to warm kotlinc, the classpath snapshot, + // the IC caches and d8, but deploy nothing: the proxy app already runs these + // sources and the generation must not move. Nothing reloaded, so there is no + // timeline to report. + val dex = compileAndDex(ChangedFiles.Unknown, timeline) + if (dex is Step.Fail) return dex.outcome + return BuildOutcome.Success(generations.current, clock() - loopStartedAt) + } + + val known = request.changes as? ChangedFiles.Known + // Removed assets must reach the packager too, or a save that only deletes one + // packages nothing and the build never deploys. + val assetCandidates = known?.files.orEmpty() + known?.removed.orEmpty() + val assets = + assetPackager.packageAssets( + changedFiles = assetCandidates, + assetRoots = layout.assetRoots(), + outFile = File(workDir, "assets-payload.zip"), + ) + + return when (request.route) { + BuildRoute.NoOp -> { + if (!request.forced) { + // The orchestrator does not start empty unforced builds; answering + // benignly keeps the executor total anyway. + BuildOutcome.Success(generations.current, 0) + } else { + // Explicit tap with nothing changed: rebuild the current sources and ship + // them at a fresh generation, which is how a relaunched proxy app on the + // gen-0 baseline catches up. Replaying the current generation cannot work + // (the runtime drops anything not strictly newer) and a null-dex payload at + // a newer generation would advance the app past the classes it claims. + // + // This route has no changed-set to derive asset candidates from (the + // classifier ran on nothing), so `assets` above is always empty here. Ship + // every asset under the roots instead: a fresh generation with no assets + // looks live but is missing everything the runtime never had, which a + // later, unrelated build would then appear to have "fixed" by accident. + val dex = compileAndDex(ChangedFiles.Unknown, timeline) + if (dex is Step.Fail) return dex.outcome + val arsc = relink(timeline) + if (arsc is Step.Fail) return arsc.outcome + payloadDeployer.deploy( + (dex as Step.Ok).decision, + dex.file, + (arsc as Step.Ok).file, + packageAllAssets(), + loopStartedAt, + timeline, + ) + } + } + + BuildRoute.CodeOnly -> { + val dex = compileAndDex(request.changes, timeline) + when (dex) { + is Step.Fail -> { + dex.outcome + } + + is Step.Ok -> { + payloadDeployer.deploy(dex.decision, dex.file, null, assets, loopStartedAt, timeline) + } + } + } + + BuildRoute.ResourcesOnly -> { + when (val arsc = relink(timeline)) { + is Step.Fail -> { + arsc.outcome + } + + is Step.Ok -> { + // No code moved, so the deploy policy has no say and a recreate + // is always enough. + payloadDeployer.deploy( + DeployDecision.Recreate, + null, + arsc.file, + assets, + loopStartedAt, + timeline, + ) + } + } + } + + BuildRoute.CodeAndResources -> { + val dex = compileAndDex(request.changes, timeline) + if (dex is Step.Fail) return dex.outcome + val arsc = relink(timeline) + if (arsc is Step.Fail) return arsc.outcome + payloadDeployer.deploy( + (dex as Step.Ok).decision, + dex.file, + (arsc as Step.Ok).file, + assets, + loopStartedAt, + timeline, + ) + } + + BuildRoute.AssetsOnly -> { + if (assets == null) { + // The classifier said assets-only but nothing packaged, for instance + // a deletion of a file that was already gone. + BuildOutcome.Success(generations.current, clock() - loopStartedAt) + } else { + payloadDeployer.deploy(DeployDecision.Recreate, null, null, assets, loopStartedAt, timeline) + } + } + + is BuildRoute.FullGradleBuild -> { + // Contract: the orchestrator never routes this here. Refuse honestly. + BuildOutcome.InfrastructureFailure( + "FullGradleBuild route must not reach the live reload path", + ) + } + + BuildRoute.WarmCompile -> { + // Handled by the early branch above; unreachable, kept for exhaustiveness. + BuildOutcome.InfrastructureFailure("WarmCompile route fell through the warm-compile branch") + } + } + } + + /** + * Compiles and dexes the changed sources, and decides how the result must be + * deployed. + * + * [ChangedFiles.Unknown] recompiles everything, re-seeding incremental state. + * + * @param changes the classified change-set; only `.kt` and `.java` entries reach the + * compiler, and removed sources travel separately so their outputs get deleted + * @param timeline mutated in place with this step's spans and counts + * @return the dex plus its deploy decision, or the outcome that ends the build + */ + private suspend fun compileAndDex( + changes: ChangedFiles, + timeline: E2eTimelineRecorder, + ): Step { + // One clock read per step boundary rather than per step, so the spans abut + // exactly and any residual is real un-timed work. + val scanStartedAt = clock() + val allSources = layout.allSources() + val scanDoneAt = clock() + timeline.recordScan(scanDoneAt - scanStartedAt) + val changedSources = + when (changes) { + ChangedFiles.Unknown -> { + allSources + } + + is ChangedFiles.Known -> { + changes.files.filter { it.extension == "kt" || it.extension == "java" } + } + } + // Removed sources are gone from disk and so absent from allSources; pass them + // separately so the incremental compiler deletes their outputs and recompiles + // dependents. Unknown re-seeds everything and needs no removed set. + val removedSources = + when (changes) { + ChangedFiles.Unknown -> { + emptyList() + } + + is ChangedFiles.Known -> { + changes.removed.filter { it.extension == "kt" || it.extension == "java" } + } + } + + val compileReply = daemon.compile(allSources, changedSources, removedSources) + val compileDoneAt = clock() + timeline.recordCompileRpc(compileDoneAt - scanDoneAt) + val compiled = + when (compileReply) { + is DaemonReply.Ok -> { + compileReply.value + } + + is DaemonReply.BuildFailed -> { + // The counts ride the outcome because this path never reaches the timeline: + // reportTimeline runs only from PayloadDeployer's success arms, which need a + // generation and a live timestamp a failed compile does not have. + return Step.Fail( + BuildOutcome.CompileError( + compileReply.diagnostics, + kotlinDeclaredChanged = compileReply.stats?.kotlinToCompile, + allSources = compileReply.stats?.allSources, + javaSources = compileReply.stats?.javaSources, + ), + ) + } + + is DaemonReply.Failed -> { + return Step.Fail(BuildOutcome.InfrastructureFailure(compileReply.message, compileReply.daemonDied)) + } + } + timeline.recordCompileSteps(compiled.kotlinMillis, compiled.javaMillis, compiled.stats) + + val decision = decideDeploy(compiled.changedClassFiles) + val policyDoneAt = clock() + timeline.recordPolicy(policyDoneAt - compileDoneAt) + + val dexReply = daemon.dex(listOfNotNull(compiled.classesDir, proxyClassesDir)) + val dexDoneAt = clock() + timeline.recordDexRpc(dexDoneAt - policyDoneAt) + return when (val reply = dexReply) { + is DaemonReply.Ok -> { + // t1: the deployable dex exists. Dexing dominates an on-device build, so + // it belongs inside compileMillis rather than after it. + timeline.markCompileDone(dexDoneAt) + timeline.recordDexSteps(reply.value.stripMillis, reply.value.d8Millis, reply.value.stats) + Step.Ok(reply.value.dexFile, decision) + } + + is DaemonReply.BuildFailed -> { + Step.Fail(BuildOutcome.CompileError(reply.diagnostics)) + } + + is DaemonReply.Failed -> { + Step.Fail(BuildOutcome.InfrastructureFailure(reply.message, reply.daemonDied)) + } + } + } + + /** + * Asks the deploy policy for a hot swap or a restart. + * + * @param changedClassFiles the changed classes; the policy reads them only to spot a compile + * that emitted nothing, since the payload carries the whole class set either way + * @return the route the deploy must take; always recreate when no policy is wired + */ + private fun decideDeploy(changedClassFiles: List?): DeployDecision = + deployPolicy?.decide(changedClassFiles) ?: DeployDecision.Recreate + + /** + * Rebuilds the resource APK from the project's current resources. + * + * @param timeline mutated in place with the relink's rpc and aapt2 spans + * @return the resource APK, or the outcome that ends the build; aapt2 errors come back + * as a compile error, since they are the user's resources failing to build + */ + private suspend fun relink(timeline: E2eTimelineRecorder): Step { + val startedAt = clock() + val reply = + daemon.relink( + RelinkInputs( + resDirs = layout.resDirs(), + manifest = proxyAppManifest ?: layout.manifest(), + stableIdsFile = layout.stableIdsFile(), + libraryResources = layout.libraryResourceFlats(), + ), + ) + timeline.recordRelinkRpc(clock() - startedAt) + return when (reply) { + is DaemonReply.Ok -> { + timeline.recordRelinkSteps(reply.value.aapt2CompileMillis, reply.value.aapt2LinkMillis) + Step.Ok(reply.value.resourceApk, DeployDecision.Recreate) + } + + // aapt2 errors are the user's resources failing to build, which is a compile + // error in the domain's sense, with aapt2's diagnostics attached. + is DaemonReply.BuildFailed -> { + Step.Fail(BuildOutcome.CompileError(reply.diagnostics)) + } + + is DaemonReply.Failed -> { + Step.Fail(BuildOutcome.InfrastructureFailure(reply.message, reply.daemonDied)) + } + } + } + + /** + * Packages every file under [QuickBuildProjectLayout.assetRoots], for a forced rebuild that + * has no changed-set to derive asset candidates from. + * + * @return the full asset set as [AssetPackager] would ship it, or null when the module has + * no assets at all. + */ + private fun packageAllAssets(): AssetPackager.PackagedAssets? = + assetPackager.packageAssets( + changedFiles = layout.assetRoots().flatMap { it.walkTopDown().filter(File::isFile).toList() }, + assetRoots = layout.assetRoots(), + outFile = File(workDir, "assets-payload.zip"), + ) + + /** Result of one pipeline step: the artifact it produced, or the outcome that ends the build. */ + private sealed interface Step { + /** + * The step produced an artifact. + * + * @property file the artifact - a dex for compile-and-dex, a resource APK for relink. + * @property decision how the artifact must be deployed; always recreate for a step + * that moved no code. + */ + data class Ok( + val file: File, + val decision: DeployDecision, + ) : Step + + /** + * The step ended the build. + * + * @property outcome the verdict to return unchanged, already in the shape the + * orchestrator routes on. + */ + data class Fail( + val outcome: BuildOutcome, + ) : Step + } + + // Internal, not private: ProxyAppBuildRunner's rebuild relaunch shares the reconnect + // bound so the two relaunch paths keep the same latency characteristics. + internal companion object { + private val log = LoggerFactory.getLogger("QB-ReloadExecutor") + + /** + * How long the runtime gets to exit after acking a restart deploy. Far more than + * it needs, so hitting it at all means the runtime ignored the request. + */ + const val DEFAULT_RESTART_DISCONNECT_TIMEOUT_MILLIS = 5_000L + + /** + * How long the relaunched process gets to boot, bind, and connect back. Sized for + * a cold app start on low-end hardware, which is also why it bounds the rebind + * wait in [PayloadDeployer]'s launch-and-retry and the rebuild relaunch's + * reconnect wait in [org.appdevforall.cotg.quickbuild.service.provision.ProxyAppBuildRunner]. + */ + const val DEFAULT_RESTART_RECONNECT_TIMEOUT_MILLIS = 15_000L + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveSession.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveSession.kt new file mode 100644 index 0000000000..73b96b9a23 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveSession.kt @@ -0,0 +1,123 @@ +package org.appdevforall.cotg.quickbuild.service.session + +import org.appdevforall.cotg.quickbuild.data.ProjectWatcher +import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo +import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout +import org.appdevforall.cotg.quickbuild.domain.annotations.AnnotationImpact +import org.appdevforall.cotg.quickbuild.domain.annotations.SwitchableAnnotationImpact +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.reload.BuildRequest +import org.appdevforall.cotg.quickbuild.domain.reload.GenerationTracker +import org.appdevforall.cotg.quickbuild.domain.reload.LiveReloadExecutor +import org.appdevforall.cotg.quickbuild.domain.reload.LiveReloadOrchestrator +import org.appdevforall.cotg.quickbuild.domain.watch.WatchFilter +import org.appdevforall.cotg.quickbuild.service.deploy.RetainedPayloadStore + +/** + * Wiring of one live Quick Build session, including what a proxy app rebuild replaces. + * + * Assembled by [LiveSessionFactory]; read and mutated only by [QuickBuildSessionManager] on the + * session dispatcher. [proxyApp] and [layout] are mutable, and [executor] and + * [annotationImpact] are switchable delegates, so a rebuild can move the session to the new + * baseline while keeping the orchestrator's pending-changes bookkeeping. + */ +internal class LiveSession( + /** The installed proxy app's baseline; replaced wholesale by [adoptBaseline]. */ + var proxyApp: ProxyAppInfo, + /** Source, resource, and watch roots derived from the same baseline as [proxyApp]. */ + var layout: QuickBuildProjectLayout, + /** Generation allocator, persisted per project so it survives this session. */ + val tracker: GenerationTracker, + /** Decides which watcher events are worth a build; fixed for the session's lifetime. */ + val filter: WatchFilter, + /** Owns coalescing, routing, and in-flight bookkeeping; survives a baseline swap. */ + val orchestrator: LiveReloadOrchestrator, + /** Started by the manager once the session goes live, and stopped by its teardown. */ + val watcher: ProjectWatcher, + /** Seam a proxy app rebuild swaps a fresh ProxyAppInfo-derived executor into. */ + val executor: SwitchableExecutor, + /** Seam a proxy app rebuild swaps a fresh annotation baseline into. */ + val annotationImpact: SwitchableAnnotationImpact, + /** + * The executor's last-deployed retention, read by the manager to answer a below-deployed + * reconnect by re-sending instead of rebuilding (concurrency.md rules 3-4). Same work-dir + * location the executor writes, so it survives an executor swap. + */ + val retainedPayloads: RetainedPayloadStore, + /** + * Build variant this session was provisioned for, or null when the provisioner does not + * track one. Fixed for the session's lifetime: a rebuild re-runs the same variant's + * assemble task, and a variant switch tears the session down rather than adopting a + * baseline from a different application id. + */ + val provisionedVariant: String? = null, +) { + /** + * Newest generation verifiably running in the proxy app: the baseline generation the + * manager adopts from the provision's stamp, advanced by every deploy that lands; -1 + * only until that adoption. + * + * Reconnect catch-up compares against this rather than the allocation counter, which + * persists across sessions and burns numbers on failed builds. A proxy app + * reconnecting below it is running superseded code. + */ + var lastDeployedGeneration = -1L + + /** + * Moves this session onto the baseline a proxy app rebuild just installed. + * + * Every ProxyAppInfo-derived piece moves together: leaving one behind lets the deploy + * policy route on provisioning-time facts, so a newly proxied service would hot-swap + * and leave its live instance stale. Callers must already hold both delegates, since + * building them can fail and a failure must leave the old baseline intact. + * + * @param proxyApp the re-read report for the app just installed + * @param layout the layout derived from that same report, never the previous one + * @param executorDelegate executor built against [proxyApp]; must already be + * constructed, since building it can throw + * @param annotationImpactDelegate annotation baseline captured against [proxyApp] + * @param baselineGeneration the generation stamped into the reinstalled APK (0 for an + * unstamped build); the fresh baseline boots at it, so a reconnect at the stamp reads + * in-sync instead of forcing a catch-up build + */ + suspend fun adoptBaseline( + proxyApp: ProxyAppInfo, + layout: QuickBuildProjectLayout, + executorDelegate: LiveReloadExecutor, + annotationImpactDelegate: AnnotationImpact, + baselineGeneration: Long, + ) { + this.proxyApp = proxyApp + this.layout = layout + executor.delegate = executorDelegate + annotationImpact.delegate = annotationImpactDelegate + // The freshly installed baseline boots at its stamp; anything deployed to the old + // epoch is gone (its runtime's generation gate discarded older persisted payloads). + lastDeployedGeneration = baselineGeneration + // Retention is cumulative over the OLD baseline only; replaying it onto the fresh + // one would resurrect code the rebuild superseded. + retainedPayloads.clear() + orchestrator.onBaselineReset() + } +} + +/** + * Lets [LiveSession] replace its executor without replacing the orchestrator. + * + * The orchestrator holds one executor for its lifetime, but a proxy app rebuild has to + * rebuild the executor from the re-read setup.json (new deploy-policy components, + * launcher and entry targets). Swapping the delegate keeps the orchestrator's + * pending-changes bookkeeping. + * + * @property delegate the executor every call forwards to; volatile because the swap runs + * on the session dispatcher while a build may read it from another thread + */ +internal class SwitchableExecutor( + @Volatile var delegate: LiveReloadExecutor, +) : LiveReloadExecutor { + override suspend fun execute(request: BuildRequest): BuildOutcome = delegate.execute(request) + + // Has a no-op default in the interface, so forwarding is not optional: without this a tap + // landing on an in-flight save-build is absorbed here and the deploy never goes foreground. + override fun markCurrentBuildUserInitiated() = delegate.markCurrentBuildUserInitiated() +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveSessionFactory.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveSessionFactory.kt new file mode 100644 index 0000000000..0597884354 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveSessionFactory.kt @@ -0,0 +1,191 @@ +package org.appdevforall.cotg.quickbuild.service.session + +import kotlinx.coroutines.CoroutineScope +import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo +import org.appdevforall.cotg.quickbuild.data.QuickBuildDaemon +import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout +import org.appdevforall.cotg.quickbuild.data.QuickBuildScratch +import org.appdevforall.cotg.quickbuild.domain.annotations.AnnotationBaseline +import org.appdevforall.cotg.quickbuild.domain.annotations.AnnotationImpact +import org.appdevforall.cotg.quickbuild.domain.annotations.AnnotationImpactAnalyzer +import org.appdevforall.cotg.quickbuild.domain.annotations.AnnotationProcessorProfile +import org.appdevforall.cotg.quickbuild.domain.annotations.SwitchableAnnotationImpact +import org.appdevforall.cotg.quickbuild.domain.classify.ChangeClassifier +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind +import org.appdevforall.cotg.quickbuild.domain.reload.DeployPolicy +import org.appdevforall.cotg.quickbuild.domain.reload.GenerationTracker +import org.appdevforall.cotg.quickbuild.domain.reload.LiveReloadExecutor +import org.appdevforall.cotg.quickbuild.domain.reload.LiveReloadOrchestrator +import org.appdevforall.cotg.quickbuild.domain.reload.OrchestratorEvent +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink +import org.appdevforall.cotg.quickbuild.domain.watch.WatchFilter +import org.appdevforall.cotg.quickbuild.service.deploy.DeploySender +import org.appdevforall.cotg.quickbuild.service.deploy.RetainedPayloadStore +import org.appdevforall.cotg.quickbuild.service.provision.ProvisionOutcome +import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppLauncher +import org.slf4j.LoggerFactory + +/** + * Assembles a [LiveSession] from a successful provision. + * + * Pure wiring: no mutable state, no back-reference into the manager. [executorFor] and + * [annotationImpactFor] are exposed because a proxy app rebuild rebuilds those two + * against the regenerated setup.json (see [LiveSession]'s switchable delegates). Call + * only on the session dispatcher; [scope] belongs to the manager and is passed through to + * the orchestrator and watcher. + */ +internal class LiveSessionFactory( + /** Warm compile server; shared by every executor this factory builds. */ + private val daemon: QuickBuildDaemon, + /** Deploy channel to the bound proxy app; shared for the same reason as [daemon]. */ + private val deploy: DeploySender, + /** App-private scratch trees (ADFA-4930); executor work dirs live here, off FUSE. */ + private val scratch: QuickBuildScratch, + /** Foregrounds the proxy app for restart deploys and for an explicit tap. */ + private val launcher: ProxyAppLauncher, + /** Analytics port handed to every executor; failures are swallowed at the call sites. */ + private val metrics: QuickBuildMetricsSink, + /** + * Monotonic clock shared by the orchestrator's t0 stamp and the executor's t1-t3, so + * the e2e timeline's stamps are comparable (see + * [org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline]). + */ + private val nowMillis: () -> Long, + /** Test seam passed through from the manager; null builds the real executor. */ + private val executorFactory: QuickBuildSessionManager.ExecutorFactory?, + /** Test seam passed through from the manager. */ + private val watcherFactory: QuickBuildSessionManager.WatcherFactory, + /** The manager's scope, not one of this factory's; its cancellation stops both children. */ + private val scope: CoroutineScope, + /** Delivered synchronously on the session dispatcher, so it must not block. */ + private val onOrchestratorEvent: (OrchestratorEvent) -> Unit, + /** This device's asset-serving capability; see [ChangeClassifier]'s parameter of the same name. */ + private val assetsLiveReloadable: Boolean, +) { + /** + * Wires a session around the provisioned proxy app, ready to accept edits. + * + * @param outcome the successful provision, source of both the layout and the baseline + * @param tracker the project's generation allocator, built by the caller so it + * outlives a baseline swap + * @return the assembled session; its watcher is created but not yet started + */ + fun create( + outcome: ProvisionOutcome.Success, + tracker: GenerationTracker, + ): LiveSession { + val layout = outcome.layout + val proxyApp = outcome.proxyApp + val executor = SwitchableExecutor(executorFor(proxyApp, layout, tracker)) + val annotationImpact = SwitchableAnnotationImpact(annotationImpactFor(proxyApp, layout)) + val orchestrator = + LiveReloadOrchestrator( + executor = executor, + classifier = + ChangeClassifier( + annotationImpact, + layout.liveReloadScope(), + assetsLiveReloadable, + ), + scope = scope, + now = nowMillis, + onEvent = onOrchestratorEvent, + ) + val filter = WatchFilter(layout.watchedRoots(), layout.watchedFiles()) + return LiveSession( + proxyApp = outcome.proxyApp, + layout = layout, + tracker = tracker, + filter = filter, + orchestrator = orchestrator, + watcher = watcherFactory.create(layout.watchedRoots(), layout.watchedFiles(), filter, scope), + executor = executor, + annotationImpact = annotationImpact, + // The same location executorFor's executor writes into, so the manager's + // reconnect re-send reads what the deploys retained. + retainedPayloads = RetainedPayloadStore.forWorkDir(scratch.workDirFor(layout.projectRoot)), + provisionedVariant = outcome.variantName, + ) + } + + /** + * Builds the executor for one proxy app baseline. Called again, and swapped in, on + * every proxy app rebuild. + * + * @param proxyApp the baseline to build against; supplies the deploy policy's + * components, the relink manifest, and both relaunch targets + * @param layout the layout derived from the same baseline + * @param tracker the session's generation allocator, carried across rebuilds + * @return the executor, or whatever the injected test factory returns + * @throws IllegalStateException when [proxyApp] carries no entry activity, which the + * provisioner rules out for a first provision but a rebuild does not + */ + fun executorFor( + proxyApp: ProxyAppInfo, + layout: QuickBuildProjectLayout, + tracker: GenerationTracker, + ): LiveReloadExecutor = + executorFactory?.create(proxyApp, layout, tracker) + ?: LiveReloadExecutorImpl( + daemon = daemon, + deploy = deploy, + layout = layout, + // Safe: the provisioner never reports Success for a null entryActivity, + // it refuses with a friendly message first. + entryActivity = + checkNotNull(proxyApp.entryActivity) { + "Quick Build session started without an entry activity" + }, + generations = tracker, + // App-private scratch, deliberately not under the FUSE-backed project root. + workDir = scratch.workDirFor(layout.projectRoot), + proxyClassesDir = proxyApp.proxyClassesDir, + proxyAppManifest = proxyApp.transformedManifest, + deployPolicy = + DeployPolicy( + components = proxyApp.components, + // Pre-v2 setup.json means a runtime that ignores restart + // deploys, so the policy routes restart-requiring builds to a + // proxy app rebuild instead. + componentInfoAvailable = proxyApp.supportsComponentInfo, + ), + proxyAppPackage = proxyApp.proxyAppPackage, + launcherActivity = + proxyApp.components + .firstOrNull { it.kind == ComponentKind.ACTIVITY && it.launcher } + ?.proxyClass, + launcher = launcher, + clock = nowMillis, + metrics = metrics, + ) + + /** + * Builds the annotation-processor awareness the classifier uses to decide which edits + * could have moved generated code. + * + * A project with no `ksp`/`kapt`/`annotationProcessor` dependency gets + * [AnnotationImpact.Inactive]; otherwise the baseline is the annotation input the proxy + * app build just ran against, so a rebuild replaces it (see [SwitchableAnnotationImpact]). + * + * @param proxyApp the baseline whose declared processors decide active versus inactive + * @param layout supplies the sources the baseline is captured from + * @return an analyzer over the captured baseline, or [AnnotationImpact.Inactive] when + * the project runs no processors + */ + fun annotationImpactFor( + proxyApp: ProxyAppInfo, + layout: QuickBuildProjectLayout, + ): AnnotationImpact { + val profile = AnnotationProcessorProfile.of(proxyApp.annotationProcessors) + if (!profile.hasProcessors) return AnnotationImpact.Inactive + log.info( + "Quick build: annotation-aware classification on for processors {}", + profile.processorCoordinates, + ) + return AnnotationImpactAnalyzer(profile, AnnotationBaseline.capture(layout.allSources(), profile)) + } + + private companion object { + private val log = LoggerFactory.getLogger("QB-SessionFactory") + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouter.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouter.kt new file mode 100644 index 0000000000..eff104cd9e --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouter.kt @@ -0,0 +1,161 @@ +package org.appdevforall.cotg.quickbuild.service.session + +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.reload.OrchestratorEvent +import org.appdevforall.cotg.quickbuild.domain.session.SessionEvent +import org.appdevforall.cotg.quickbuild.domain.session.SessionFailure +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink +import org.appdevforall.cotg.quickbuild.service.telemetry.report +import org.slf4j.LoggerFactory + +/** + * Translates orchestrator events into session events, and reports each one to metrics. + * + * The inbound half of the session shell, mirroring the manager's `runEffect` on the way + * out. It decides only: [route] mutates no session state and dispatches nothing, and the + * manager applies the returned [Routing]. + * + * @property metrics reported to for every event, through [report], so a failing sink can + * never change what the router decides + */ +internal class OrchestratorEventRouter( + private val metrics: QuickBuildMetricsSink, +) { + /** + * What one orchestrator event translates to. The manager applies the fields in + * declaration order: advance the tally, dispatch the events, then notify. + */ + data class Routing( + /** Dispatched in order, after the tally advances; empty means the event is silent. */ + val sessionEvents: List = emptyList(), + /** + * Value the session's deploy tally must advance to before the events dispatch, already + * maxed against the current tally; null leaves the tally alone. + */ + val newLastDeployedGeneration: Long? = null, + /** + * Generation to tell the proxy app it is still running while a newer build compiles, + * preferring the session's own deploy tally over the connected target's self-reported + * generation - which is fresh only at connect time - and null when there is nothing + * truthful to say. + */ + val notifyBuildingAt: Long? = null, + ) + + /** + * Decides what one orchestrator event means for the session, and reports it to + * metrics. + * + * @param event the orchestrator fact to translate + * @param lastDeployedGeneration the session's own deploy tally, seeded with the + * provisioned baseline's stamped generation before the first deploy; -1 when no + * session is live + * @param connectedGeneration the bound proxy app's self-reported generation, or null + * when none is connected + * @return what the manager must apply, in field-declaration order + */ + fun route( + event: OrchestratorEvent, + lastDeployedGeneration: Long, + connectedGeneration: Long?, + ): Routing = + when (event) { + is OrchestratorEvent.BuildStarted -> { + report { metrics.onBuildStarted(event.buildId, event.route, event.changes) } + if (event.route is BuildRoute.WarmCompile) { + // A warm compile recompiles what the proxy app already runs and + // deploys nothing, so neither surface should say "building". This + // event keeps the IDE status on "up to date". + Routing(sessionEvents = listOf(SessionEvent.WarmCompileStarted)) + } else { + Routing( + sessionEvents = listOf(SessionEvent.BuildStarted), + notifyBuildingAt = + lastDeployedGeneration.takeIf { it >= 0 } ?: connectedGeneration, + ) + } + } + + is OrchestratorEvent.BuildSucceeded -> { + report { metrics.onBuildFinished(event.buildId, event.result) } + if (event.route is BuildRoute.WarmCompile) { + // Nothing deployed, generation unmoved: no Deployed state, no + // lastDeployedGeneration bump. + Routing(sessionEvents = listOf(SessionEvent.WarmCompileFinished)) + } else { + Routing( + sessionEvents = + listOf( + SessionEvent.BuildSucceeded( + event.result.generation, + event.result.durationMillis, + event.result.restarted, + userInitiated = event.userInitiated, + ), + ), + newLastDeployedGeneration = + maxOf(lastDeployedGeneration, event.result.generation), + ) + } + } + + is OrchestratorEvent.BuildFailed -> { + report { metrics.onBuildFinished(event.buildId, event.outcome) } + val outcome = event.outcome + if (outcome is BuildOutcome.RequiresProxyAppRebuild) { + // The build was fine but the baseline cannot take the deploy. The + // orchestrator already returned the changed set to pending, so the + // proxy app rebuild absorbs it. + log.info("Quick build routed to a proxy app rebuild: {}", outcome.detail) + report { metrics.onInvalidation(outcome.reason) } + Routing(sessionEvents = listOf(SessionEvent.InvalidationDetected(outcome.reason))) + } else if (outcome is BuildOutcome.InfrastructureFailure && outcome.daemonDied) { + // Includes a daemon death mid-warm-compile: the normal respawn recovery + // re-seeds with ChangedFiles.Unknown, so no warm-compile-specific path. + Routing(sessionEvents = listOf(SessionEvent.DaemonDied)) + } else if (event.route is BuildRoute.WarmCompile) { + // A failed warm compile stays invisible: the proxy app build just + // compiled these sources green, and the next real save compiles the + // full source set anyway. + log.warn("Background warm compile failed (not surfaced): {}", outcome) + Routing(sessionEvents = listOf(SessionEvent.WarmCompileFinished)) + } else { + Routing(sessionEvents = listOf(SessionEvent.BuildFailed(outcome.toSessionFailure()))) + } + } + + is OrchestratorEvent.InvalidationRequired -> { + // The event carries only the reason, not the paths that proved it - those + // live on the orchestrator's pending set, which this router never sees. + log.info("Quick build invalidated: {}", event.reason) + report { metrics.onInvalidation(event.reason) } + Routing(sessionEvents = listOf(SessionEvent.InvalidationDetected(event.reason))) + } + } + + /** + * Narrows a build outcome to the failure shape the session state carries. + * + * @return the user-facing failure; kept total over every outcome, so the two cases + * that cannot reach here still map rather than throw + */ + private fun BuildOutcome.toSessionFailure(): SessionFailure = + when (this) { + is BuildOutcome.CompileError -> SessionFailure.CompileError(diagnostics) + + is BuildOutcome.DeployFailure -> SessionFailure.DeployError(message) + + is BuildOutcome.InfrastructureFailure -> SessionFailure.DeployError(message) + + // Handled as an invalidation before this mapping; keep it total anyway. + is BuildOutcome.RequiresProxyAppRebuild -> SessionFailure.DeployError(detail) + + // Success never reaches BuildFailed; keep the mapping total anyway. + is BuildOutcome.Success -> SessionFailure.DeployError("unexpected success in failure path") + } + + private companion object { + private val log = LoggerFactory.getLogger("QB-EventRouter") + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildHistoryStore.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildHistoryStore.kt new file mode 100644 index 0000000000..e51bfc046a --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildHistoryStore.kt @@ -0,0 +1,25 @@ +package org.appdevforall.cotg.quickbuild.service.session + +/** + * Remembers what the currently open project has done with Quick Build across CoGo runs. + * + * Backed by CoGo's project preferences in the app module, never the user's gradle files. + */ +interface QuickBuildHistoryStore { + /** + * True once this project has tapped Quick Build at least once. Recorded for + * analytics; the eager prebuild does not gate on it (see + * [QuickBuildSessionManager.prebuild]). + * + * @return true when a tap was recorded in this or an earlier CoGo run + */ + fun hasUsedQuickBuild(): Boolean + + /** + * Records that this project has now tapped Quick Build. + * + * @param used the value to persist; callers only ever set it true, since nothing + * un-taps a project + */ + fun setHasUsedQuickBuild(used: Boolean) +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManager.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManager.kt new file mode 100644 index 0000000000..2333e551c2 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManager.kt @@ -0,0 +1,1425 @@ +package org.appdevforall.cotg.quickbuild.service.session + +import android.content.ComponentCallbacks2 +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.appdevforall.cotg.quickbuild.data.AndroidProjectWatcher +import org.appdevforall.cotg.quickbuild.data.FileGenerationStore +import org.appdevforall.cotg.quickbuild.data.ProjectWatcher +import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo +import org.appdevforall.cotg.quickbuild.data.QuickBuildDaemon +import org.appdevforall.cotg.quickbuild.data.QuickBuildPaths +import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout +import org.appdevforall.cotg.quickbuild.data.QuickBuildScratch +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.classify.TestSourceFilter +import org.appdevforall.cotg.quickbuild.domain.classify.recompilesCode +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind +import org.appdevforall.cotg.quickbuild.domain.reload.GenerationStore +import org.appdevforall.cotg.quickbuild.domain.reload.GenerationTracker +import org.appdevforall.cotg.quickbuild.domain.reload.LiveReloadExecutor +import org.appdevforall.cotg.quickbuild.domain.reload.LiveReloadOrchestrator +import org.appdevforall.cotg.quickbuild.domain.reload.LiveReloadRequestOutcome +import org.appdevforall.cotg.quickbuild.domain.reload.OrchestratorEvent +import org.appdevforall.cotg.quickbuild.domain.reload.isRestartSensitive +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildNotice +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildSessionState +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildStatus +import org.appdevforall.cotg.quickbuild.domain.session.SessionEffect +import org.appdevforall.cotg.quickbuild.domain.session.SessionEvent +import org.appdevforall.cotg.quickbuild.domain.session.SessionReducer +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink +import org.appdevforall.cotg.quickbuild.domain.watch.WatchFilter +import org.appdevforall.cotg.quickbuild.domain.watch.WatcherBatchReconciler +import org.appdevforall.cotg.quickbuild.service.deploy.BuildStatusJson +import org.appdevforall.cotg.quickbuild.service.deploy.DeployResult +import org.appdevforall.cotg.quickbuild.service.deploy.DeploySender +import org.appdevforall.cotg.quickbuild.service.deploy.ProxyAppConnections +import org.appdevforall.cotg.quickbuild.service.deploy.TargetReport +import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppBuildRunner +import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppLauncher +import org.appdevforall.cotg.quickbuild.service.provision.QuickBuildProvisioner +import org.appdevforall.cotg.quickbuild.service.telemetry.report +import org.slf4j.LoggerFactory +import java.io.File + +/** + * How many neutral notices wait for a collector before the oldest is dropped. + * + * Small on purpose: this is the burst the user is flashed on their way back into the editor, and + * four stale one-line notices is already at the edge of useful. + */ +internal const val NOTICE_QUEUE_DEPTH = 4 + +/** How many failure messages wait for a collector before the oldest is dropped. */ +internal const val USER_MESSAGE_QUEUE_DEPTH = 8 + +/** + * The shell around the domain session machine: owns the [SessionReducer] and the live session, + * and turns reducer effects into provisioning, daemon respawn, and Gradle proxy app rebuilds. + * + * Everything stateful runs on [dispatcher]. Effects are launched rather than run inline so a + * reducer dispatch never re-enters itself, and that dispatcher's single thread is what keeps + * the launched work ordered. + */ +class QuickBuildSessionManager( + /** Warm compile server; its death listener is wired here, in [init]. */ + private val daemon: QuickBuildDaemon, + /** + * Deploy channel, used directly only for the best-effort build-status pushes and for + * re-sending the retained payload on a stale reconnect (see [resendRetainedPayload]). + */ + private val deploy: DeploySender, + /** The door to Gradle for the proxy app build, rebuild, and prebuild. */ + private val provisioner: QuickBuildProvisioner, + /** Deploy-channel registry; also the source of crash and reconnect signals. */ + private val connections: ProxyAppConnections, + /** Bundled toolchain locations, passed straight through to the daemon controller. */ + private val paths: QuickBuildPaths, + /** Gates eager prebuild on project history and records first use. */ + private val historyStore: QuickBuildHistoryStore, + /** + * Confines everything stateful. Must be single-threaded: the orchestrator's + * event-ordering guarantee depends on it. + */ + dispatcher: CoroutineDispatcher, + /** Opens the project's persisted generation counter, keyed by its root directory. */ + private val generationStoreFactory: (File) -> GenerationStore = { + FileGenerationStore.forProject(it) + }, + /** Test seam; null builds the real executor. */ + private val executorFactory: ExecutorFactory? = null, + /** Test seam: the default builds the real on-device [AndroidProjectWatcher]. */ + private val watcherFactory: WatcherFactory = + WatcherFactory { roots, files, filter, scope -> + AndroidProjectWatcher(roots, files, filter, scope) + }, + /** Run-statistics port; the app wires an analytics sink. */ + private val metrics: QuickBuildMetricsSink = QuickBuildMetricsSink.Noop, + /** + * Relaunches the proxy app after a restart deploy or a proxy app rebuild's reinstall; + * the app wires an intent-based implementation. The default refuses, which the executor + * surfaces as a deploy failure telling the user to open the app, rather than claiming a + * relaunch it cannot do. + */ + private val launcher: ProxyAppLauncher = ProxyAppLauncher { _, _ -> false }, + /** + * Bench seam gating the background warm compile fired when provisioning succeeds, so a + * warm-compile-off arm of an A/B run needs a flag file rather than a rebuild. Read at + * effect time, per session; always true outside bench runs. The daemon-respawn re-warm + * is deliberately not gated, since it repairs a dead daemon rather than a cold one. + */ + private val warmCompileEnabled: () -> Boolean = { true }, + /** + * Monotonic clock shared by the e2e timeline's orchestrator and executor stamps, so + * they are comparable (see [org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline]). + * + * Defaults to `System.currentTimeMillis` so this module's unit tests run without an + * Android runtime; the app's Koin graph injects `SystemClock.elapsedRealtime`. + */ + private val nowMillis: () -> Long = System::currentTimeMillis, + /** + * Per-project scratch trees on app-private storage, keeping intermediates off FUSE. + * Overridable so tests can shrink or inflate the disk-space floor. + */ + private val scratch: QuickBuildScratch = QuickBuildScratch(paths.projectScratchRoot), + /** + * Whether this device can serve a deployed asset payload - the runtime's asset overlay + * needs the API 30+ `ResourcesLoader`. False routes asset-bearing edits to Gradle instead + * of acking a reload the app cannot see; the app's Koin graph reads the device's SDK level. + * Defaults to the capable path so this module's unit tests need no Android runtime. + */ + private val assetsLiveReloadable: Boolean = true, +) { + /** Builds the project watcher for a live session; overridden with a fake in tests. */ + fun interface WatcherFactory { + /** + * Builds a watcher over one session's watch set. + * + * @param roots directories to watch recursively + * @param files individual files to watch that lie outside [roots] + * @param filter decides which raw events are worth reporting + * @param scope the manager's scope, so its cancellation stops the watcher too + * @return a watcher that observes nothing until it is started + */ + fun create( + roots: List, + files: List, + filter: WatchFilter, + scope: CoroutineScope, + ): ProjectWatcher + } + + /** Test seam: build the executor for a freshly provisioned session. */ + fun interface ExecutorFactory { + /** + * Builds the executor for one proxy app baseline. + * + * @param proxyApp the baseline just built and installed + * @param layout the layout derived from that same baseline + * @param tracker the session's generation allocator, shared across rebuilds + * @return the executor the session's switchable delegate will point at + */ + fun create( + proxyApp: ProxyAppInfo, + layout: QuickBuildProjectLayout, + tracker: GenerationTracker, + ): LiveReloadExecutor + } + + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + private val reducer = SessionReducer() + + private val _state = + MutableStateFlow(QuickBuildSessionState.Idle()) + + /** Raw session-machine state; UI should prefer the derived [status]. */ + val state: StateFlow = _state + + /** + * What the toolbar shows. Derived from [state] and never set imperatively, so a banner + * cannot get stuck out of step with the session. + */ + val status: StateFlow = + _state + .map(QuickBuildStatus.Companion::from) + .stateIn(scope, SharingStarted.Eagerly, QuickBuildStatus.Hidden()) + + private val _userMessages = + Channel( + capacity = USER_MESSAGE_QUEUE_DEPTH, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** + * Provisioning and daemon failure text for the host UI to flash. The editor activity + * collects it, since the Koin graph cannot reach an Activity's flash helpers. + * + * A QUEUE, not a broadcast. The only collector lives inside `repeatOnLifecycle(STARTED)`, + * so it is gone for anything raised while the user is in their proxy app - which is where + * most of this text is raised, and `ReinstallReturnToCoGo` by construction: it asks the user + * to come back to CoGo. A message waits until a collector attaches, is handed to exactly one + * of them, and is never replayed. Replay was the cheap fix and was rejected: the collector + * re-subscribes on every STARTED transition, so a replayed message re-flashes on every return + * to the editor. + * + * SINGLE-CONSUMER by contract - a second collector silently steals messages from the first. + */ + val userMessages: Flow = _userMessages.receiveAsFlow() + + private val _notices = + Channel( + capacity = NOTICE_QUEUE_DEPTH, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + onUndeliveredElement = ::onNoticeUndelivered, + ) + + /** + * Neutral notices for the host UI: things that are not failures and must not be + * flashed as errors. + * + * Separate from [userMessages] because that flow is the error channel, and a + * cancellation the user asked for should not read as a red banner. Same queue semantics and + * the same single-consumer contract. + */ + val notices: Flow = _notices.receiveAsFlow() + + private var live: LiveSession? = null + + /** + * Bumped by every [teardown], so in-flight work can tell it was outlived. + * + * Provisioning and rebuild work captures the epoch at launch and discards its result + * when they differ: a provision completing after "Restart session" must never install + * itself as a zombie session with a live watcher and daemon behind an Idle UI. Only + * touched on [dispatcher]. + */ + private var sessionEpoch = 0L + + /** The in-flight provision, prebuild, or proxy app rebuild; cancelled by [teardown]. */ + private var sessionWork: Job? = null + + /** + * Whether [SessionEffect.CancelProxyAppBuild] already cancelled this session's Gradle build, + * so [teardown] does not ask a second time. The stop-tap path emits that effect and a + * teardown; every OTHER teardown (a restart, an invalidation, a project close) emits only the + * teardown, which is the case teardown's own cancel exists for. Cleared in [teardown], which + * always follows the effect. + */ + private var proxyAppBuildCancelIssued = false + + /** + * [teardown]'s asynchronous tail - the daemon shutdown and the scratch-tree removal. + * + * Awaited by [SessionEffect.TeardownAndProvision] so a user-requested restart cannot start a + * daemon into a shutdown still in flight. Deliberately not awaited by an ordinary + * [SessionEffect.StartProvisioning]: a tap after a teardown may go live while that tail runs, + * which the scratch-tree check makes safe. Only touched on [dispatcher]. + */ + private var teardownWork: Job? = null + + /** + * True once [QuickBuildNotice.STALE_COMPONENT_HELPERS] has been shown for this session. + * + * The gap holds for every hot-swap deploy, so re-flashing it on each save would bury the + * notices that report something happening. Cleared by [provision], the one path that can owe + * it again - the next session may be a different project - while a proxy app rebuild does not + * re-arm it, since the fact is about the app being edited. + * + * Set on [dispatcher], but also cleared by [onNoticeUndelivered] on whatever thread dropped + * the queued warning, hence `@Volatile`. + */ + @Volatile private var staleComponentHelpersNoticed = false + + /** + * True once [QuickBuildNotice.TEST_SOURCE_IGNORED] has been shown for this session. + * + * Once is the whole design: a user editing tests saves constantly, and repeating "that did not + * deploy" on every one of them is noise that buries the notices which report something + * happening. Same clearing rules and the same `@Volatile` reason as + * [staleComponentHelpersNoticed]. + */ + @Volatile private var testSourceIgnoredNoticed = false + + /** + * When ([nowMillis]) a request to bring the proxy app forward arrived while a full Gradle + * build held the screen; null when no ask is waiting. The ask waits for that build instead + * of stranding the user in a stale app. A re-defer behind a chained build preserves the + * stamp, so the expiry ages the ask from the original request. + * + * See [switchToProxyApp] for why leaving mid-build is worse than making the user wait, and + * [settleDeferredForegroundAsk] for when it is answered, expired or dropped. Only touched + * on [dispatcher]. + */ + private var foregroundAskDeferredAtMillis: Long? = null + + /** Owns the daemon lifecycle protocol; see [QuickBuildDaemonController]. */ + private val daemonController = QuickBuildDaemonController(daemon, scratch, paths) + + /** Assembles live sessions and the rebuild pieces derived from a proxy app baseline. */ + private val sessionFactory = + LiveSessionFactory( + daemon = daemon, + deploy = deploy, + scratch = scratch, + launcher = launcher, + metrics = metrics, + nowMillis = nowMillis, + executorFactory = executorFactory, + watcherFactory = watcherFactory, + scope = scope, + onOrchestratorEvent = ::onOrchestratorEvent, + assetsLiveReloadable = assetsLiveReloadable, + ) + + /** + * Runs the Gradle proxy app builds and returns verdicts. This manager keeps the epoch + * guards, installs sessions, and dispatches. + */ + private val buildRunner = + ProxyAppBuildRunner( + provisioner = provisioner, + daemonController = daemonController, + connections = connections, + deploy = deploy, + launcher = launcher, + scratch = scratch, + sessionFactory = sessionFactory, + generationStoreFactory = generationStoreFactory, + metrics = metrics, + ) + + /** Translates orchestrator facts into session events; see [onOrchestratorEvent]. */ + private val eventRouter = OrchestratorEventRouter(metrics) + + init { + daemon.setDeathListener { exitCode -> + log.warn("Quick-build daemon death observed (exit {})", exitCode) + scope.launch { dispatch(SessionEvent.DaemonDied) } + } + scope.launch { + connections.reports.collect { report -> + if (report is TargetReport.Crashed) { + // Accepted limitation, but not a silent one: the ATTENTION icon alone + // would leave the user watching a crash with no idea that only a + // session restart clears it. Told on every crash, not once: each + // reload reproduces it, and the report cannot tell a bad payload from + // a bug in the code the user just wrote. + surfaceNotice(QuickBuildNotice.RELOAD_CRASHED) + dispatch(SessionEvent.ProxyAppCrashed(report.stackSummary)) + } + } + } + scope.launch { + // Reconnect catch-up: a relaunched proxy app reports the generation it + // booted, and one below what this session deployed means its persisted + // payload was lost or stale; left alone it runs old code silently until the + // next edit. First choice is re-sending the retained last-deployed payload + // at its original generation (concurrency.md rules 3-4): the stamped + // baseline makes any below-deployed reconnect same-baseline, and the app + // runs something strictly older than the retained generation, so the + // runtime's newer-only gate accepts the replay. Only when retention is + // missing or the re-send fails does the forced rebuild of current sources + // run, as last-resort repair. + connections.target.collect { target -> + val session = live ?: return@collect + if (target == null || target.runningGeneration >= session.lastDeployedGeneration) { + return@collect + } + if (resendRetainedPayload(session, target.runningGeneration)) return@collect + log.info( + "Proxy app reconnected at generation {} but the session deployed {}; " + + "no retained payload to re-send, forcing a catch-up build", + target.runningGeneration, + session.lastDeployedGeneration, + ) + // Not user-initiated: nobody tapped anything, and saying otherwise + // would foreground the proxy app off a stale reconnect. + session.orchestrator.onLiveReloadRequested(userInitiated = false) + } + } + scope.launch { + // Retries a low-memory teardown the controller deferred while a build was in + // flight, the moment that build's own transition lands (success, failure, or + // a real daemon death all move the state away from Building). + _state.collect { + daemonController.shrinkIfPending(buildInFlight = it is QuickBuildSessionState.Building) + } + } + scope.launch { + // Stale-tree sweep. Nothing can be live yet - this manager is the process's + // only session owner and no tap has dispatched - so every tree under the + // scratch root belongs to a dead session or a deleted project. Runs on + // dispatcher, strictly before any tap. + scratch.sweep() + } + } + + /** + * Handles the Quick Build tap: starts a session from Idle, triggers a build when live, + * and queues onto an in-flight prebuild. + * + * The tap must dispatch before the history write, never after: behind a disk write it + * could be reduced after `PrebuildFinished` already settled back to Idle, and a write + * that throws would lose the tap outright. Nothing depends on the other ordering. + * + * @param wroteSomething whether the tap's save-all wrote at least one file - the one bit + * the tap carries; the watcher stays the single changeset source, so no filenames cross + * this boundary. True routes the tap through the watcher batch those writes produce; + * false with nothing pending switches to the proxy app without building, since the + * deployed app is already current. + */ + fun onQuickBuildTapped(wroteSomething: Boolean = false) { + scope.launch { + dispatch(SessionEvent.QuickBuildTapped(wroteSomething)) + try { + historyStore.setHasUsedQuickBuild(true) + } catch (e: Throwable) { + log.warn("Could not record Quick Build history for this project", e) + } + } + } + + /** + * Handles the stop button, the same toolbar button showing its stop icon. + * + * Safe to call from any state: the reducer only acts on states that own a build the + * user asked for, so a tap that raced the build's completion does nothing. + */ + fun onCancelRequested() { + scope.launch { dispatch(SessionEvent.CancelRequested) } + } + + /** + * An editor save reached the host's save path. Call from the editor's save funnel, on + * every save. + * + * Only a failed-start Idle acts on it, clearing the stale error tone; the save never + * retries the start (a retry stays a tap). Every other state ignores it - a live session + * learns about saves from its own watcher, so this must never trigger a build. + */ + fun onFileSaved() { + scope.launch { dispatch(SessionEvent.FileSaved) } + } + + /** + * Retries a reinstall whose confirm dialog never appeared, now that CoGo is + * foreground again. Call from the editor's onResume. + * + * A reinstall that ran with CoGo backgrounded shows nothing: Android defers + * PENDING_USER_ACTION until foreground, and the lifecycle-bound dialog subscriber may + * not have re-registered when it lands. A no-op outside an Invalidated session. + */ + fun onHostForegrounded() { + scope.launch { dispatch(SessionEvent.HostForegrounded) } + } + + /** + * Runs the proxy app build in the background so the first tap pays only install and + * bind. Call at project open, after the normal Gradle sync completes. + * + * Installs nothing, and is a no-op unless Idle; a tap landing mid-warm queues and + * provisions when the warm build finishes. Not gated on project history, which would make + * a new project's first tap pay a cold build - about 97 s for a small app [measured on a56]. + */ + fun prebuild() { + scope.launch { dispatch(SessionEvent.PrebuildRequested) } + } + + /** + * The editor's project-sync-completed hook: warms an idle session, reprovisions a variant switch. + * + * Applying a Build Variants selection re-syncs the project, and a live session provisioned + * for the old variant would keep hot-reloading into it - a different application id once + * flavors carry a suffix, so the user edits one app and watches another. A plain sync + * compares equal and behaves exactly like [prebuild]. + * + * @param selectedVariant the Build Variants selection now in effect, or null when the + * project model cannot name one - which never restarts, since an unknown variant is not + * evidence of a change + */ + fun onProjectSynced(selectedVariant: String? = null) { + scope.launch { + val provisioned = live?.provisionedVariant + if (provisioned != null && selectedVariant != null && provisioned != selectedVariant) { + log.info( + "Build variant changed from {} to {}; reprovisioning the Quick Build session", + provisioned, + selectedVariant, + ) + dispatch(SessionEvent.SessionRestartAndReprovisionRequested) + } else { + dispatch(SessionEvent.PrebuildRequested) + } + } + } + + /** + * Moves a live session back onto current disk after a Standard Run's Gradle build, so + * the next quick build is not stale. Call from the Run button's build-finished hook. + * + * A build that clobbered the proxy app artifacts forces a full rebuild; anything less + * only marks the baseline dirty. No-op with no live session. + */ + fun onStandardRunCompleted() { + scope.launch { dispatch(SessionEvent.ExternalBuildCompleted) } + } + + /** + * Tears down the live session and daemon and returns to Idle from any state, so the + * next tap re-provisions from scratch. + * + * The internal half of the escape hatch: for callers that want the session gone and nothing + * started in its place - a project closing, or a Standard Run about to install over the proxy + * app. A user who asked to restart wants [restartSessionAndReprovision]. + */ + fun restartSession() { + scope.launch { dispatch(SessionEvent.SessionRestartRequested) } + } + + /** + * Tears the session and daemon down from any state and immediately provisions a fresh one. + * + * The escape hatch as the user meets it - the long-press menu's "Restart session" and the + * won't-stay-up dialog. Both mean a fresh proxy app build, the only thing that clears a + * baked-in startup crash or an unresolvable resource reference, and what the dialog's copy + * already promises. + */ + fun restartSessionAndReprovision() { + scope.launch { dispatch(SessionEvent.SessionRestartAndReprovisionRequested) } + } + + /** + * Gives the compile daemon's memory back under system pressure. The host forwards + * `ComponentCallbacks2.onTrimMemory`'s level here. + * + * The daemon is a separate child JVM whose heap is pure overhead between builds, so it + * is the first thing worth releasing. Which levels tear it down, and why a build in + * flight defers, is [QuickBuildDaemonController.onTrimMemory]. + * + * @param level the raw `ComponentCallbacks2` level, forwarded unfiltered - the + * threshold rules live in the controller, not in the host + */ + fun onTrimMemory(level: Int) { + scope.launch { + daemonController.onTrimMemory( + level, + buildInFlight = _state.value is QuickBuildSessionState.Building, + ) + } + } + + /** + * Hands one coalesced batch of watcher changes to the orchestrator, which picks the + * route and handles any in-flight build. + * + * Reconciling modified against removed is domain logic in [WatcherBatchReconciler]; + * this shell only supplies the `File.isFile` probe. + * + * @param batch one coalesced watcher batch, before reconciliation; a batch that + * reconciles to empty is dropped rather than passed on as a no-change build + */ + private fun onWatcherBatch(batch: ChangedFiles.Known) { + val reconciled = WatcherBatchReconciler.reconcile(batch, File::isFile) + if (reconciled.isEmpty) return + // Test sources are watched but never built - see [TestSourceFilter]. Dropped HERE rather + // than routed and classified: a route still travels through the orchestrator's pending + // set, where the next forced tap would rebuild it, so the only place a save can be truly + // ignored is before it becomes pending work. + val split = TestSourceFilter.split(reconciled) + if (split.droppedTestSources) { + noticeTestSourceIgnored() + } + if (split.buildable.isEmpty) return + val buildable = split.buildable + log.debug( + "Watcher batch: {} modified [{}], {} removed [{}]", + buildable.files.size, + describePaths(buildable.files), + buildable.removed.size, + describePaths(buildable.removed), + ) + scope.launch { + live?.orchestrator?.onFilesChanged(buildable) + } + } + + /** + * Says once per session that a test-source save does not deploy. + * + * Latched on the queue accepting it, not on raising it, for the same reason + * [noticeStaleComponentHelpers] is: the user may be in the proxy app with the editor's + * lifecycle-bound collector gone, and spending the session's one explanation on nobody would + * leave the next test save silently unexplained. + */ + private fun noticeTestSourceIgnored() { + if (testSourceIgnoredNoticed) return + if (surfaceNotice(QuickBuildNotice.TEST_SOURCE_IGNORED)) { + testSourceIgnoredNoticed = true + } + } + + /** + * Renders a path set for a log line, capped so a large batch (save-all, `git pull`) does + * not flood logcat with one line per file. + * + * @param paths the set to render; order is whatever the set iterates in + * @return up to 20 paths, comma-separated, with a "+N more" tail when truncated + */ + private fun describePaths(paths: Set): String { + val shown = paths.take(20) + val remainder = paths.size - shown.size + val listing = shown.joinToString(", ") { it.path } + return if (remainder > 0) "$listing, +$remainder more" else listing + } + + /** + * Reduces one event into the new state and runs its effects. On [dispatcher] only. + * + * @param event the event to reduce; the reducer is total, so an event the current + * state does not care about is a silent no-op rather than an error + */ + private suspend fun dispatch(event: SessionEvent) { + val transition = reducer.reduce(_state.value, event) + if (transition.state != _state.value) { + log.info("Quick-build session: {} -> {} on {}", _state.value, transition.state, event) + } + _state.value = transition.state + transition.effects.forEach(::runEffect) + settleDeferredForegroundAsk(transition.state) + } + + /** + * Turns one reducer effect into real work, launched so a dispatch never re-enters itself. + * + * @param effect the effect to carry out; the launches land in order because + * [dispatcher] is single-threaded + */ + private fun runEffect(effect: SessionEffect) { + when (effect) { + SessionEffect.StartProvisioning -> { + val epoch = sessionEpoch + sessionWork = scope.launch { provision(epoch) } + } + + SessionEffect.StartProxyAppPrebuild -> { + sessionWork = scope.launch { runPrebuild() } + } + + is SessionEffect.TriggerLiveReload -> { + scope.launch { triggerLiveReload(effect.userInitiated, effect.expectChanges) } + } + + SessionEffect.MarkBuildUserInitiated -> { + scope.launch { + val orchestrator = live?.orchestrator ?: return@launch + // The build can finish between the reducer's decision and this + // effect; fall back to a real request rather than let the tap + // vanish. expectChanges is false because the tap's saves either + // rode along in the build that just finished or are pending already. + if (!orchestrator.markInFlightUserInitiated()) triggerLiveReload(userInitiated = true) + } + } + + SessionEffect.SwitchToProxyApp -> { + switchToProxyApp() + } + + SessionEffect.CancelLiveReload -> { + scope.launch { + // Only report a cancellation that really happened: a stop that lost the + // race to the build's own completion cancelled nothing. + if (live?.orchestrator?.onCancelRequested() == true) { + surfaceNotice(QuickBuildNotice.BUILD_CANCELLED) + } + } + } + + SessionEffect.CancelProxyAppBuild -> { + // Emitted only from states where this session owns the device's single + // Gradle slot; see QuickBuildProvisioner.cancelProxyAppBuild for why + // issuing it otherwise would be dangerous. + proxyAppBuildCancelIssued = true + if (provisioner.cancelProxyAppBuild()) { + log.info("Quick Build proxy app build cancelled by the user") + } else { + // The Gradle build had already finished and the session is in its + // install or daemon-spawn tail. The TeardownSession effect that + // follows still stops the session. + log.info("No Quick Build proxy app build to cancel; tearing the session down instead") + } + surfaceNotice(QuickBuildNotice.BUILD_CANCELLED) + } + + SessionEffect.StartWarmCompile -> { + if (warmCompileEnabled()) { + // live is assigned before ProvisioningSucceeded is dispatched, so + // the orchestrator is always there to take this. + scope.launch { live?.orchestrator?.onWarmCompileRequested() } + } else { + log.info("Background warm compile disabled (bench seam); session stays Ready unwarmed") + } + } + + SessionEffect.RunProxyAppRebuild -> { + val epoch = sessionEpoch + sessionWork = scope.launch { rebuildProxyApp(epoch) } + } + + SessionEffect.RefreshBaseline -> { + scope.launch { refreshBaseline() } + } + + SessionEffect.RespawnDaemon -> { + val epoch = daemonController.epochSnapshot() + scope.launch { respawnDaemon(epoch) } + } + + is SessionEffect.SurfaceProvisioningError -> { + log.error("Quick-build provisioning failed: {}", effect.message) + surfaceUserMessage(effect.message) + teardown() + } + + is SessionEffect.SurfaceMessage -> { + // Deliberately no teardown: this is the recoverable counterpart to + // SurfaceProvisioningError, for a session that stays up. + surfaceUserMessage(effect.message) + } + + SessionEffect.TeardownSession -> { + log.info("Quick-build session restarted by user request") + teardown() + } + + SessionEffect.TeardownAndProvision -> { + log.info("Quick-build session restarted by user request; provisioning a fresh one") + teardown() + // After teardown, so it reads the epoch the teardown just bumped and any late + // completion of the OLD session is discarded rather than adopted. + val epoch = sessionEpoch + val pendingTeardown = teardownWork + sessionWork = + scope.launch { + // The daemon shutdown teardown launched is still in flight; starting the + // new daemon into it would hand that shutdown the new daemon to kill. + pendingTeardown?.join() + provision(epoch) + } + } + } + } + + /** + * Asks the orchestrator for a build, and foregrounds the app when a tap has nothing to + * wait for. + * + * The decision lives here rather than in the reducer because only the orchestrator knows + * what is pending. + * + * @param userInitiated true only for a real tap; a reconnect catch-up must pass false, + * since foregrounding the app off a stale reconnect would steal the screen + * @param expectChanges the tap's save-all wrote at least one file, so the answer should + * ride the watcher batch those writes produce (see [SessionEffect.TriggerLiveReload]) + */ + private suspend fun triggerLiveReload( + userInitiated: Boolean, + expectChanges: Boolean = false, + ) { + val orchestrator = live?.orchestrator ?: return + when (orchestrator.onLiveReloadRequested(userInitiated, expectChanges)) { + // Nothing written and nothing pending: the deployed app is current, so the tap + // is answered right now and no build runs. If the proxy app's process is dead + // the switch relaunches it, and payload persistence plus the reconnect catch-up + // bring it back in sync. + LiveReloadRequestOutcome.SWITCH_NOW -> if (userInitiated) switchToProxyApp() + + // A build owns the ask; its deploy brings the app forward (or its failure + // answers the tap with the error). + LiveReloadRequestOutcome.AWAITS_DEPLOY -> Unit + + LiveReloadRequestOutcome.AWAITS_CHANGES -> scheduleTapSwitchFallback() + } + } + + /** + * Backstop for a tap armed on a watcher batch that never comes: the save-all wrote only + * watcher-irrelevant files (a `.md`, say), so nothing will consume the armed tap and no + * deploy would ever answer it. After the deadline, whoever still holds the unanswered tap + * switches; a batch that arrived first already consumed it and this is a no-op - either + * way the tap is answered exactly once. + */ + private fun scheduleTapSwitchFallback() { + scope.launch { + delay(TAP_SWITCH_FALLBACK_MILLIS) + if (live?.orchestrator?.consumeUnansweredTap() == true) { + log.info( + "Quick Build tap saw no watcher batch within {} ms; switching to the proxy app anyway", + TAP_SWITCH_FALLBACK_MILLIS, + ) + switchToProxyApp() + } + } + } + + /** + * Brings the proxy app to the foreground because the user asked. + * + * Best-effort: a refusal is logged rather than surfaced, since the build already + * landed and the user can open the app themselves. + * + * Held back while a full Gradle build is in flight - see [settleDeferredForegroundAsk]. + */ + private fun switchToProxyApp() { + val session = live ?: return + if (fullGradleBuildInFlight()) { + // Leaving now shows the user the app they already had, for as long as the Gradle + // build takes, and it breaks the build's own install: the confirmation is a dialog + // only CoGo can raise, and Android does not deliver PENDING_USER_ACTION to a + // backgrounded app. The ask is answered when the rebaseline lands, dropped if it + // does not, and expired if landing takes so long the ask has gone stale. + log.info("Quick Build asked for the proxy app mid-full-build; deferring until it lands") + // A re-defer keeps the original stamp: the expiry ages the ask from the user's + // tap, and re-stamping here would let N chained sub-bound builds keep an + // arbitrarily old ask alive. Only a genuinely new ask starts a fresh clock. + foregroundAskDeferredAtMillis = foregroundAskDeferredAtMillis ?: nowMillis() + return + } + foregroundAskDeferredAtMillis = null + // Same target the restart-deploy relaunch uses: the proxied launcher activity + // when one carries MAIN/LAUNCHER, else null so the launcher falls back to the + // default launch intent, which resolves an launcher. + val launcherActivity = + session.proxyApp.components + .firstOrNull { it.kind == ComponentKind.ACTIVITY && it.launcher } + ?.proxyClass + if (!launcher.launch(session.proxyApp.proxyAppPackage, launcherActivity)) { + log.warn("Could not bring the proxy app {} to the foreground", session.proxyApp.proxyAppPackage) + } + } + + /** + * Whether the session is inside a full Gradle build - a first provision or a rebaseline. + * + * Read off the state rather than from the route that asked for the switch, so no caller can + * bring the app forward mid-build. Only the states that own a Gradle build count - + * [QuickBuildSessionState.Invalidated] does when a rebuild is running, and does not once it + * has parked awaiting a retry, since then nothing is coming for the ask to wait on. + * + * @return true when the proxy app must not be brought forward yet. + */ + private fun fullGradleBuildInFlight(): Boolean = + when (val state = _state.value) { + is QuickBuildSessionState.Provisioning -> true + is QuickBuildSessionState.Prebuilding -> state.tapQueued + is QuickBuildSessionState.Invalidated -> !state.awaitingRetry + else -> false + } + + /** + * Answers, expires or drops a foreground request that waited for a full Gradle build. + * + * Answered the moment the session is live again, which is what "not until the rebaseline is + * done" means - unless the ask has aged past [DEFERRED_FOREGROUND_ASK_MAX_AGE_MILLIS], in + * which case it expires: a stale ask must not beat where the user is now. Dropped when the + * build did not get there - a dead session or a park - because the app the user would land + * in is the stale one they asked to be taken away from, and showing it would read as the + * rebuild having worked. + * + * @param state the state just adopted. + */ + private fun settleDeferredForegroundAsk(state: QuickBuildSessionState) { + val askedAtMillis = foregroundAskDeferredAtMillis ?: return + when { + state is QuickBuildSessionState.Ready || state is QuickBuildSessionState.Deployed -> { + val ageMillis = nowMillis() - askedAtMillis + if (ageMillis > DEFERRED_FOREGROUND_ASK_MAX_AGE_MILLIS) { + log.info( + "Quick Build's deferred proxy app switch expired after {} ms: " + + "the user has moved on since asking", + ageMillis, + ) + foregroundAskDeferredAtMillis = null + return + } + // switchToProxyApp clears the ask itself, and re-checks the guard - a + // rebaseline that lands straight into another full build has to keep the + // ask waiting on its ORIGINAL stamp, so chained builds cannot keep an + // aging ask alive past the bound. + switchToProxyApp() + } + + state is QuickBuildSessionState.Idle || + (state is QuickBuildSessionState.Invalidated && state.awaitingRetry) -> { + log.info("Quick Build's deferred proxy app switch dropped: the full build did not land") + foregroundAskDeferredAtMillis = null + } + + else -> { + // Still building, installing or spawning the daemon; keep waiting. + } + } + } + + /** Runs the eager warm-up build. Silent on failure, and always reports finished. */ + private suspend fun runPrebuild() { + try { + provisioner.prebuildProxyApp() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Throwable) { + log.warn("Eager quick-build proxy app build failed; first tap will retry", e) + } + dispatch(SessionEvent.PrebuildFinished) + } + + /** + * Provisions a session and installs it as [live], unless a teardown outlived it. + * + * @param startEpoch the session epoch read when this effect fired; a later mismatch is + * what tells a completing provision that the user already restarted the session + */ + private suspend fun provision(startEpoch: Long) { + when (val result = buildRunner.provision(superseded = { startEpoch != sessionEpoch })) { + is ProxyAppBuildRunner.ProvisionResult.DiskSpaceShort -> { + dispatch(SessionEvent.ProvisioningFailed(result.message)) + } + + is ProxyAppBuildRunner.ProvisionResult.Failed -> { + dispatch(SessionEvent.ProvisioningFailed(result.message)) + } + + is ProxyAppBuildRunner.ProvisionResult.Superseded -> { + // The user asked for a fresh start while the proxy app build ran, so a + // late success must not resurrect and a late failure must not surface. + log.info("Quick-build provisioning outlived a session restart; discarding") + } + + is ProxyAppBuildRunner.ProvisionResult.SupersededDuringDaemonStart -> { + // A restart raced the daemon start. The runner already undid its side; + // stop the zombie daemon on a fresh coroutine, since this one is + // already cancelled. + log.info("Session restarted during daemon start; shutting down") + daemonController.markIntentionalTransition() + scope.launch { daemonController.shutdown() } + } + + is ProxyAppBuildRunner.ProvisionResult.Succeeded -> { + live = result.session + staleComponentHelpersNoticed = false + testSourceIgnoredNoticed = false + // A same-project predecessor's scratch tree can survive its teardown (see + // [teardown]'s skip when a new session went live mid-shutdown); whatever it + // retained belongs to another baseline and must not answer this session's + // reconnects. + result.session.retainedPayloads.clear() + // The installed APK boots at the stamped baseline generation (concurrency.md + // rule 2): the allocator must stay strictly above it, and adopting it as the + // deploy tally makes a reconnect at the stamp read in-sync by construction. + result.tracker.adoptAtLeast(result.baselineGeneration) + result.session.lastDeployedGeneration = result.baselineGeneration + // Build ids restart per session; give the sink its session boundary. + report { metrics.onSessionStarted() } + // The reload path is change-driven, not save-driven: any source of a + // file change triggers it, including Termux, plugins and git. + result.session.watcher.start(::onWatcherBatch) + dispatch(SessionEvent.ProvisioningSucceeded(result.baselineGeneration)) + } + } + } + + /** + * Applies what [eventRouter] made of one orchestrator event. The orchestrator + * delivers synchronously on [dispatcher], so this hops to a launch. + * + * @param event the orchestrator fact; routing decides, this applies, and the order of + * the three steps below is part of the contract + */ + private fun onOrchestratorEvent(event: OrchestratorEvent) { + scope.launch { + val session = live + val routing = + eventRouter.route( + event, + lastDeployedGeneration = session?.lastDeployedGeneration ?: -1L, + connectedGeneration = connections.target.value?.runningGeneration, + ) + // Tally first, because the dispatched BuildSucceeded's consumers may read + // it; events second; the best-effort building notification last. + routing.newLastDeployedGeneration?.let { generation -> + session?.lastDeployedGeneration = generation + } + routing.sessionEvents.forEach { dispatch(it) } + routing.notifyBuildingAt?.let { generation -> + // With no live session there is nothing truthful to say, so skip + // silently like every other best-effort status push. + if (session != null) notifyBuilding(generation) + } + if (event is OrchestratorEvent.BuildSucceeded) noticeStaleComponentHelpers(event, session) + // The orchestrator decides when a repeating aapt2 rejection has become blocking; all + // that is owed here is saying it, since the status surface only ever shows the + // diagnostics and never that they are now stopping every save. + if (event is OrchestratorEvent.BuildFailed && event.relinkStuck) { + surfaceNotice(QuickBuildNotice.RELINK_STUCK) + } + // Same deal for the deploy half: the orchestrator decides when "not connected" has + // stopped being transient, and all that is owed here is saying it - loudly, because + // the status surface's own advice ("relaunch to reconnect") is the one action that + // cannot work. + if (event is OrchestratorEvent.BuildFailed && event.proxyAppWontStayUp) { + surfaceNotice(QuickBuildNotice.PROXY_APP_WONT_STAY_UP) + } + } + } + + /** + * Warns, once per session, that a landed hot swap left a live service, provider or custom + * `Application` calling the previous copies of the classes it just replaced. + * + * The restart closure ([org.appdevforall.cotg.quickbuild.domain.reload.DeployPolicy]) covers a + * component's own code and its supertypes, but not a helper class the component merely calls. + * That gap is accepted, so all that is owed to the user is saying it out loud. CoGo's own + * injected components are exempt here as they are there ([isRestartSensitive]), or every + * ordinary app would get this warning about code the user did not write. + * + * @param event the deploy that landed; a warm compile deployed nothing, a restart deploy + * already relaunched the process, and a route that moved no class file cannot have + * staled anything + * @param session the live session, read for the baseline's component list; null means the + * session went away and there is nothing truthful to say + */ + private fun noticeStaleComponentHelpers( + event: OrchestratorEvent.BuildSucceeded, + session: LiveSession?, + ) { + if (staleComponentHelpersNoticed || session == null) return + if (event.route is BuildRoute.WarmCompile || !event.route.recompilesCode) return + if (event.result.restarted) return + if (session.proxyApp.components.none { it.isRestartSensitive() }) return + // Latch only a warning that is genuinely owed. This fires on a hot-swap deploy, which lands + // while the user is in the proxy app, so the editor's lifecycle-bound collector is usually + // gone; the queue holds it for their return, and an eviction re-arms the latch. + if (surfaceNotice(QuickBuildNotice.STALE_COMPONENT_HELPERS)) { + staleComponentHelpersNoticed = true + } + } + + /** + * Tells the proxy app a newer build is compiling while it keeps running + * [runningGeneration], so a slow build does not read as silence. + * + * Which generation that is comes from + * [OrchestratorEventRouter.Routing.notifyBuildingAt]. + * + * @param runningGeneration what the app is still running, never the one being built + */ + private fun notifyBuilding(runningGeneration: Long) { + try { + deploy.notifyBuildStatus(BuildStatusJson.building(runningGeneration)) + } catch (e: Exception) { + log.warn("Build-starting notification failed", e) + } + } + + /** + * Tells the proxy app its update is waiting on an install confirmation only CoGo can + * show, so the user watching the stale app knows to switch back. + * + * Without this the park is invisible from the proxy app: every other recovery signal + * (snackbar, Build Output, toolbar tone) is in CoGo, which is exactly the app the user + * is not looking at while Android defers the confirm dialog. + */ + private fun notifyReinstallPending() { + try { + deploy.notifyBuildStatus(BuildStatusJson.reinstallPending()) + } catch (e: Exception) { + log.warn("Reinstall-pending notification failed", e) + } + } + + /** + * Rebuilds the proxy app and moves the live session onto the new baseline. + * + * @param startEpoch the session epoch read when this effect fired; a mismatch means + * the session this rebuild was for is gone and its orchestrator must not be poked + */ + private suspend fun rebuildProxyApp(startEpoch: Long) { + val session = live ?: return + // Captured before ProxyAppRebuildStarted moves the session to Provisioning, which + // carries neither the reason nor the deployed generation: a retry that never gets + // the Gradle slot has to park back exactly where it came from. + val rebuildPark = _state.value as? QuickBuildSessionState.Invalidated + val installRetryPark = + rebuildPark?.takeIf { it.reason == InvalidationReason.INSTALL_NOT_CONFIRMED } + session.orchestrator.onProxyAppRebuildStarted() + dispatch(SessionEvent.ProxyAppRebuildStarted) + + val result = + buildRunner.rebuildProxyApp( + parkedRetry = installRetryPark != null, + superseded = { startEpoch != sessionEpoch }, + ) + + when (result) { + is ProxyAppBuildRunner.ProxyAppRebuildResult.Superseded -> { + // The session this rebuild was for is gone; do not poke its orchestrator. + log.info("Quick-build proxy app rebuild outlived a session restart; discarding") + } + + is ProxyAppBuildRunner.ProxyAppRebuildResult.BuildSlotBusy -> { + if (installRetryPark != null) { + // The retry never got the Gradle slot, usually to CoGo's own project + // sync that the invalidating gradle edit triggered. Park back + // without spending the auto-retry budget, and say what is actually + // happening: the park's own text tells the user to return to CoGo, + // which is exactly what triggered this retry. + log.info("Gradle slot busy; deferring the proxy app rebuild retry without spending an auto-retry") + surfaceUserMessage(QuickBuildMessage.ReinstallWaitingForGradle) + notifyReinstallPending() + dispatch(SessionEvent.ProxyAppRebuildDeferred(installRetryPark.deployedGeneration)) + } else { + // A first rebuild has no park to return to and no budget to + // protect, so report it like any other proxy-app-build failure. + session.orchestrator.onProxyAppRebuildFailed() + dispatch(SessionEvent.ProvisioningFailed(QuickBuildMessage.RebuildFailed)) + } + } + + is ProxyAppBuildRunner.ProxyAppRebuildResult.Succeeded -> { + try { + // Both delegates are built before adoptBaseline moves anything: + // executorFor can throw on a null entryActivity, which the rebuild + // contract does not rule out, and a throw must leave the old + // baseline intact rather than escape with the session half-updated. + val executorDelegate = + sessionFactory.executorFor(result.proxyApp, result.layout, session.tracker) + val annotationImpactDelegate = + sessionFactory.annotationImpactFor(result.proxyApp, result.layout) + // The reinstalled APK boots at its stamp; the session's allocator must + // stay strictly above it or the runtime rejects every later deploy. + session.tracker.adoptAtLeast(result.baselineGeneration) + session.adoptBaseline( + result.proxyApp, + result.layout, + executorDelegate, + annotationImpactDelegate, + result.baselineGeneration, + ) + // A rebuild that skipped the reinstall (bytes already matched, e.g. the + // deferred confirm completed while parked) leaves the old process - and + // any reinstall-pending banner - running; clear it explicitly. After a + // real reinstall the send just misses the dead connection, harmlessly. + try { + deploy.notifyBuildStatus(BuildStatusJson.buildOk()) + } catch (e: Exception) { + log.warn("Post-rebuild status clear failed", e) + } + dispatch(SessionEvent.ProvisioningSucceeded(result.baselineGeneration)) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Throwable) { + log.error("Re-baselining after a successful proxy app rebuild threw", e) + session.orchestrator.onProxyAppRebuildFailed() + dispatch(SessionEvent.ProvisioningFailed(QuickBuildMessage.Literal(e.message ?: e.javaClass.name))) + } + } + + is ProxyAppBuildRunner.ProxyAppRebuildResult.DaemonRestartFailed -> { + log.error("Daemon restart after a proxy app rebuild failed: {}", result.message) + session.orchestrator.onProxyAppRebuildFailed() + dispatch(SessionEvent.ProvisioningFailed(QuickBuildMessage.DaemonRestartFailed(result.message))) + } + + is ProxyAppBuildRunner.ProxyAppRebuildResult.Failed -> { + // Nothing was absorbed - the Gradle build never produced a baseline - so the held + // batch goes back to pending and invalidation re-arms. That re-arming is what lets + // the save of the FIX re-report the invalidation and retry; without it the park + // below would wait forever for a tap the user has no reason to make. It emits no + // event, so a still-broken build file does not loop. + session.orchestrator.onProxyAppRebuildFailed() + val park = rebuildPark + if (park != null) { + // Park recoverable instead of dying to Idle: the session and the running proxy + // app are both fine; what failed is the user's build files. The message is + // surfaced here rather than through SurfaceProvisioningError, whose effect + // tears the session down. + surfaceUserMessage(result.message) + dispatch(SessionEvent.ProxyAppRebuildFailed(park.reason, park.deployedGeneration)) + } else { + // No invalidation to park back into (a rebuild from an unexpected state): + // fail provisioning rather than invent a park with no reason. + dispatch(SessionEvent.ProvisioningFailed(result.message)) + } + } + + is ProxyAppBuildRunner.ProxyAppRebuildResult.InstallNotConfirmed -> { + // The Gradle build was fine and only the reinstall confirmation is + // missing, so park recoverable instead of dying to Idle; the message + // already says how to recover. Deliberately not onProxyAppRebuildFailed: + // the orchestrator keeps holding the absorbed batch, and every held file + // is on disk for the retry's Gradle build to absorb. + log.warn("Proxy app rebuild reinstall not confirmed; awaiting a retry: {}", result.message) + surfaceUserMessage(result.message) + notifyReinstallPending() + dispatch( + SessionEvent.ProxyAppRebuildInstallNotConfirmed( + session.lastDeployedGeneration.takeIf { it >= 0 } ?: session.tracker.current, + ), + ) + } + } + } + + /** + * Brings the session back in step after an external full build. + * + * With the daemon's proxy app artifacts still on disk, marking the baseline dirty is + * enough: the next build recompiles everything and reinstalls nothing. If the external + * build removed them, only a full rebuild helps. + */ + private suspend fun refreshBaseline() { + val session = live ?: return + if (buildRunner.proxyAppArtifactsIntact(session.proxyApp)) { + session.orchestrator.onBaselineUntrusted() + } else { + log.warn("Proxy app build artifacts missing after an external build; forcing a proxy app rebuild") + dispatch(SessionEvent.InvalidationDetected(InvalidationReason.EXTERNAL_FULL_BUILD)) + } + } + + /** + * Answers a below-deployed reconnect by re-sending the retained last-deployed payload at + * its original generation, instead of rebuilding bytes the session already holds. + * + * Replayable only when the retained generation IS the deploy tally: an older retained set + * (a later deploy whose retention write failed) would leave the app still behind with + * nothing left to notice it. Every failure just reports false and the caller falls back + * to the forced catch-up build, so this path can never make recovery worse - only cheaper. + * + * @param session the live session whose retention to read + * @param runningGeneration what the reconnected app reports running + * @return true when the app confirmed the re-sent payload and no build is needed + */ + private suspend fun resendRetainedPayload( + session: LiveSession, + runningGeneration: Long, + ): Boolean { + val retained = session.retainedPayloads.load() ?: return false + if (retained.generation != session.lastDeployedGeneration) return false + log.info( + "Proxy app reconnected at generation {} but the session deployed {}; re-sending the retained payload", + runningGeneration, + retained.generation, + ) + val result = + deploy.deploy( + retained.generation, + retained.dexFile, + retained.arscFile, + retained.assetsZip, + retained.metadataJson, + ) + if (result is DeployResult.Reloaded) return true + log.warn( + "Re-send of retained generation {} failed ({}); falling back to a catch-up build", + retained.generation, + result, + ) + return false + } + + /** + * Restarts a dead daemon and re-seeds the orchestrator against it. + * + * @param startEpoch the daemon epoch read when this effect fired, not the session + * epoch; the controller's exactly-one-transition rule is stated against it + */ + private suspend fun respawnDaemon(startEpoch: Long) { + val session = live ?: return + when (val outcome = daemonController.respawn(session.layout, session.proxyApp, startEpoch)) { + is QuickBuildDaemonController.RespawnOutcome.Respawned -> { + dispatch(SessionEvent.DaemonRespawned) + // A fresh daemon has no trustworthy incremental state. With nothing + // pending this re-warms via a deploy-nothing warm compile, leaving the + // proxy app on its current generation; with pending work it marks the + // baseline dirty so the next build recompiles everything and deploys. Not + // gated on [warmCompileEnabled]: this repairs a daemon that lost its state. + session.orchestrator.onDaemonReplaced() + } + + // The controller already stopped any zombie daemon per its + // exactly-one-transition rule; the successor flow owns the lifecycle. + is QuickBuildDaemonController.RespawnOutcome.Superseded -> { + Unit + } + + is QuickBuildDaemonController.RespawnOutcome.Failed -> { + log.error("Daemon respawn failed: {}", outcome.message) + // Stay Degraded and let the next explicit tap or session restart retry; + // auto-retrying a hard-broken daemon would just spin. The event schedules + // nothing either - it stops the status claiming a restart is still under way, + // which is the half the snackbar cannot fix. + dispatch(SessionEvent.DaemonRestartFailed) + surfaceUserMessage(QuickBuildMessage.DaemonRestartFailed(outcome.message)) + } + } + } + + /** + * Tears down the live session and any in-flight provision, prebuild, or rebuild. + * + * The epoch bump and cancel pair is what makes "Restart session" safe mid-provisioning: + * without it a provision resuming after the restart would set [live], start its + * watcher, and deploy invisibly behind an Idle UI, and the next tap would overwrite + * [live] leaving that watcher orphaned. Cancelling [sessionWork] from inside it is safe. + */ + private fun teardown() { + sessionEpoch++ + daemonController.markIntentionalTransition() + // Cancelling the coroutine abandons the await, not the build: Gradle runs out of process + // behind a future, so an uncancelled proxy app build keeps the device's one build slot and + // the reprovision behind this teardown fails SlotBusy - a user-requested "Restart session" + // reported as a setup failure. The provisioner refuses unless the in-flight build is this + // session's own, so calling it whenever there is session work is safe. The stop tap has + // its own cancel effect, hence the guard: two cancels for one build is not harmful, but + // it is a contract the stop-path tests pin. + if (sessionWork != null && !proxyAppBuildCancelIssued) provisioner.cancelProxyAppBuild() + proxyAppBuildCancelIssued = false + sessionWork?.cancel() + sessionWork = null + live?.watcher?.stop() + val scratchOwner = live?.layout?.projectRoot + live = null + connections.endSession() + teardownWork = + scope.launch { + daemonController.shutdown() + // Only after the daemon is down, since it writes into this tree until + // then. A teardown with no live session has nothing to remove, and the + // init-time sweep reclaims any half-made tree. Skip when a new session for + // the same project went live while shutdown suspended: the tree is that + // session's now. + scratchOwner + ?.takeIf { live?.layout?.projectRoot != it } + ?.let(scratch::remove) + } + } + + /** + * Queues failure text for [userMessages], for whenever the editor is next on screen. + * + * @param message user-facing failure text; this is the error channel, so anything that + * is not a failure belongs in [surfaceNotice] instead + */ + private fun surfaceUserMessage(message: QuickBuildMessage) { + _userMessages.trySend(message) + } + + /** + * Queues a non-failure notice for [notices], for whenever the editor is next on screen. + * + * @param notice the neutral notice; when the queue is full the OLDEST waiting notice is + * dropped, since a stale notice is worth less than the newest one + * @return whether the notice is now owed to a collector. Callers that latch a once-per-session + * notice must gate on this - and [onNoticeUndelivered] re-arms that latch if the queue + * later drops the notice, so "owed" never quietly becomes "lost". + */ + private fun surfaceNotice(notice: QuickBuildNotice): Boolean = _notices.trySend(notice).isSuccess + + /** + * Re-arms a once-per-session latch for a notice that was queued but never reached anybody: + * either the queue overflowed and dropped it, or a collector was cancelled mid-handoff. + * + * Without this, latching on [surfaceNotice] would spend the session's one warning on a notice + * that was silently evicted - the same defect as latching on an emit nobody heard, moved one + * step later. + * + * Runs on whichever thread lost the element: the sender (on [dispatcher]) for an overflow, the + * collector's thread for a cancelled receive. Hence the `@Volatile` on the latch. + */ + private fun onNoticeUndelivered(notice: QuickBuildNotice) { + when (notice) { + QuickBuildNotice.STALE_COMPONENT_HELPERS -> staleComponentHelpersNoticed = false + QuickBuildNotice.TEST_SOURCE_IGNORED -> testSourceIgnoredNoticed = false + else -> Unit + } + } + + private companion object { + private val log = LoggerFactory.getLogger("QB-SessionManager") + + /** + * Oldest a deferred foreground ask may be and still be answered when the full build + * lands. Manual QA (2026-08-13, F5) saw a rebaseline settle a 34-second-old ask on + * top of a user who had deliberately returned to the editor mid-typing; past ~10 s + * the ask no longer says anything about where the user wants to be. + */ + private const val DEFERRED_FOREGROUND_ASK_MAX_AGE_MILLIS = 10_000L + + /** + * How long a tap armed on its save-all's watcher batch waits before switching anyway. + * + * The coalescer emits at most 250 ms after the last file event (1 s cap from the + * first), so 2 s comfortably covers watcher, coalescer and dispatch latency; a batch + * still absent by then means the save-all wrote only watcher-irrelevant files and no + * batch is coming, and the tap must not go unanswered. + */ + private const val TAP_SWITCH_FALLBACK_MILLIS = 2_000L + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/README.md b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/README.md new file mode 100644 index 0000000000..6ca1bde406 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/README.md @@ -0,0 +1,13 @@ +# `service/session/` - the live Quick Build session and its lifecycle + +This folder is the session itself: the shell that owns the domain `SessionReducer`, holds the one live session's wiring, and turns reducer effects into real work (provision, daemon respawn, proxy app rebuild, live reload). Everything stateful runs on a single-threaded dispatcher so the orchestrator's event ordering holds, and effects are launched rather than run inline so a dispatch never re-enters itself. Depends down on `domain/`; the pieces here reference each other freely. + +| File | Purpose | +| --- | --- | +| [`QuickBuildSessionManager.kt`](QuickBuildSessionManager.kt) | Top-level shell: owns the reducer, state flows, and live session; wires daemon-death, crash, reconnect, and low-memory signals; runs each `SessionEffect`. | +| [`LiveReloadExecutorImpl.kt`](LiveReloadExecutorImpl.kt) | Runs one classified change-set through compile/dex/relink on the warm daemon, then deploys; every failure becomes a `BuildOutcome`, and a generation is burned only once the build reaches deploy. | +| [`LiveSession.kt`](LiveSession.kt) | Holds one live session's wiring (orchestrator, watcher, tracker, filter, mutable baseline); `adoptBaseline` moves it onto a rebuilt proxy app, and `SwitchableExecutor` swaps the executor without replacing the orchestrator. | +| [`LiveSessionFactory.kt`](LiveSessionFactory.kt) | Pure wiring that assembles a `LiveSession` from a successful provision; also rebuilds the executor and annotation baseline against a re-read proxy app on rebuild. | +| [`QuickBuildDaemonController.kt`](QuickBuildDaemonController.kt) | Owns the compile daemon's lifecycle: the epoch rule for detecting superseded respawns, respawn cleanup, and the low-memory teardown policy. | +| [`OrchestratorEventRouter.kt`](OrchestratorEventRouter.kt) | Translates each orchestrator event into session events plus tally/notify instructions the manager applies, and reports every event to metrics. | +| [`QuickBuildHistoryStore.kt`](QuickBuildHistoryStore.kt) | Interface for remembering whether the open project has ever tapped Quick Build, persisted across CoGo runs (analytics only; does not gate prebuild). | diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/session/FailedStartToneTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/session/FailedStartToneTest.kt new file mode 100644 index 0000000000..f2a592592b --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/session/FailedStartToneTest.kt @@ -0,0 +1,133 @@ +package org.appdevforall.cotg.quickbuild.domain.session + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * A failed session START must keep the error tone on the bolt until the user's next tap or save + * (Q8): `Idle -> Provisioning -> Idle on ProvisioningFailed` used to land in a plain Idle whose + * bolt reads READY, so the user saw a green bolt right after the failure flash. + */ +class FailedStartToneTest { + private val reducer = SessionReducer() + + private fun failedStartIdle(): QuickBuildSessionState { + val provisioning = + reducer.reduce(QuickBuildSessionState.Idle(), SessionEvent.QuickBuildTapped()).state + assertThat(provisioning).isInstanceOf(QuickBuildSessionState.Provisioning::class.java) + return reducer + .reduce(provisioning, SessionEvent.ProvisioningFailed(QuickBuildMessage.Literal("boom"))) + .state + } + + @Test + fun `a failed start reads as ERROR, not READY`() { + val state = failedStartIdle() + + assertThat(QuickBuildStatus.from(state).toTone()).isEqualTo(QuickBuildTone.ERROR) + } + + @Test + fun `a tap after a failed start provisions again with the ordinary BUILDING tone`() { + val transition = reducer.reduce(failedStartIdle(), SessionEvent.QuickBuildTapped()) + + assertThat(transition.state) + .isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) + assertThat(transition.effects).containsExactly(SessionEffect.StartProvisioning) + assertThat(QuickBuildStatus.from(transition.state).toTone()) + .isEqualTo(QuickBuildTone.BUILDING) + } + + @Test + fun `a successful start after a failed one shows ordinary tones throughout`() { + val provisioning = reducer.reduce(failedStartIdle(), SessionEvent.QuickBuildTapped()).state + val ready = reducer.reduce(provisioning, SessionEvent.ProvisioningSucceeded(1)).state + + assertThat(ready).isEqualTo(QuickBuildSessionState.Ready(1)) + assertThat(QuickBuildStatus.from(ready).toTone()).isEqualTo(QuickBuildTone.READY) + } + + @Test + fun `the failed start lands in Idle with the flag and the status carries it`() { + val state = failedStartIdle() + + assertThat(state).isEqualTo(QuickBuildSessionState.Idle(lastStartFailed = true)) + assertThat(QuickBuildStatus.from(state)) + .isEqualTo(QuickBuildStatus.Hidden(lastStartFailed = true)) + } + + @Test + fun `a save clears the tone and does NOT retry the start`() { + val transition = reducer.reduce(failedStartIdle(), SessionEvent.FileSaved) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Idle()) + // No effect at all: the save is the clearing gesture, never a provision. + assertThat(transition.effects).isEmpty() + assertThat(QuickBuildStatus.from(transition.state).toTone()).isEqualTo(QuickBuildTone.READY) + } + + @Test + fun `a save on a plain Idle is a no-op`() { + val transition = reducer.reduce(QuickBuildSessionState.Idle(), SessionEvent.FileSaved) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `a save on a live session is a no-op - the watcher owns live saves`() { + val ready = QuickBuildSessionState.Ready(3) + + val transition = reducer.reduce(ready, SessionEvent.FileSaved) + + assertThat(transition.state).isEqualTo(ready) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `a prebuild round-trip does not silently clear the failed-start tone`() { + // A gradle-save-triggered project sync fires the prebuild; the warm build still runs + // (pinned behaviour) but must not clear the tone on its way through - its outcome is + // silent, and only a tap or a save is a user gesture. + val prebuild = reducer.reduce(failedStartIdle(), SessionEvent.PrebuildRequested) + assertThat(prebuild.state) + .isEqualTo(QuickBuildSessionState.Prebuilding(lastStartFailed = true)) + assertThat(prebuild.effects).containsExactly(SessionEffect.StartProxyAppPrebuild) + assertThat(QuickBuildStatus.from(prebuild.state).toTone()).isEqualTo(QuickBuildTone.ERROR) + + val finished = reducer.reduce(prebuild.state, SessionEvent.PrebuildFinished) + assertThat(finished.state).isEqualTo(QuickBuildSessionState.Idle(lastStartFailed = true)) + assertThat(QuickBuildStatus.from(finished.state).toTone()).isEqualTo(QuickBuildTone.ERROR) + } + + @Test + fun `a tap queued on the warm build clears the tone and reads BUILDING`() { + val prebuilding = reducer.reduce(failedStartIdle(), SessionEvent.PrebuildRequested).state + + val tapped = reducer.reduce(prebuilding, SessionEvent.QuickBuildTapped()) + + assertThat(tapped.state) + .isEqualTo(QuickBuildSessionState.Prebuilding(tapQueued = true)) + assertThat(QuickBuildStatus.from(tapped.state).toTone()).isEqualTo(QuickBuildTone.BUILDING) + } + + @Test + fun `a save during the warm build clears the tone without touching the build`() { + val prebuilding = reducer.reduce(failedStartIdle(), SessionEvent.PrebuildRequested).state + + val saved = reducer.reduce(prebuilding, SessionEvent.FileSaved) + + assertThat(saved.state).isEqualTo(QuickBuildSessionState.Prebuilding()) + assertThat(saved.effects).isEmpty() + assertThat(QuickBuildStatus.from(saved.state).toTone()).isEqualTo(QuickBuildTone.READY) + } + + @Test + fun `an explicit session teardown clears the failed-start tone`() { + val transition = reducer.reduce(failedStartIdle(), SessionEvent.SessionRestartRequested) + + // Project close / Standard Run takeover: the tone must not survive into what follows. + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(transition.effects).isEmpty() + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildStatusTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildStatusTest.kt new file mode 100644 index 0000000000..b9cf9fd95a --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildStatusTest.kt @@ -0,0 +1,174 @@ +package org.appdevforall.cotg.quickbuild.domain.session + +import com.google.common.truth.Truth.assertThat +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic +import org.junit.jupiter.api.Test + +class QuickBuildStatusTest { + @Test + fun `idle maps to hidden`() { + assertThat(QuickBuildStatus.from(QuickBuildSessionState.Idle())) + .isEqualTo(QuickBuildStatus.Hidden()) + } + + @Test + fun `provisioning maps to provisioning`() { + assertThat(QuickBuildStatus.from(QuickBuildSessionState.Provisioning())) + .isEqualTo(QuickBuildStatus.Provisioning()) + } + + @Test + fun `who asked for a provision does not change what the surface shows`() { + // userInitiated exists to decide where the user ENDS UP, not what the status line and + // the toolbar icon say. Leaking it into the derived status would also break the + // StateFlow conflation the toolbar repaint depends on. + assertThat(QuickBuildStatus.from(QuickBuildSessionState.Provisioning(userInitiated = true))) + .isEqualTo(QuickBuildStatus.from(QuickBuildSessionState.Provisioning(userInitiated = false))) + } + + @Test + fun `background prebuilding maps to hidden - the user never asked for it`() { + assertThat(QuickBuildStatus.from(QuickBuildSessionState.Prebuilding(tapQueued = false))) + .isEqualTo(QuickBuildStatus.Hidden()) + } + + @Test + fun `prebuilding with a queued tap maps to provisioning`() { + assertThat(QuickBuildStatus.from(QuickBuildSessionState.Prebuilding(tapQueued = true))) + .isEqualTo(QuickBuildStatus.Provisioning()) + } + + @Test + fun `ready with no failure maps to up to date`() { + val state = QuickBuildSessionState.Ready(3) + + assertThat(QuickBuildStatus.from(state)) + .isEqualTo(QuickBuildStatus.UpToDate(3, buildDurationMillis = null)) + } + + // An error state must never map to Building, or the banner sticks on "Compiling...". + @Test + fun `ready with a failure maps to failed`() { + val failure = + SessionFailure.CompileError( + listOf(BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "msg", "A.kt", 1, 1)), + ) + val state = QuickBuildSessionState.Ready(3, lastFailure = failure) + + assertThat(QuickBuildStatus.from(state)).isEqualTo(QuickBuildStatus.Failed(3, failure)) + } + + @Test + fun `building maps to building`() { + val state = QuickBuildSessionState.Building(3) + + assertThat(QuickBuildStatus.from(state)).isEqualTo(QuickBuildStatus.Building(3)) + } + + // The background warm compile deploys nothing and the proxy + // app is genuinely current - it must not present as a blocking Building for its + // whole 12-50s window. + @Test + fun `a warm-compiling build maps to up to date, not building`() { + val state = QuickBuildSessionState.Building(3, warmingCompiler = true) + + assertThat(QuickBuildStatus.from(state)) + .isEqualTo(QuickBuildStatus.UpToDate(3, buildDurationMillis = null)) + } + + // A crash of the running generation observed + // mid-warm-compile surfaces immediately, exactly as it would outside the warm-compile window. + @Test + fun `a warm-compiling build with a pending crash maps to failed`() { + val crash = SessionFailure.ProxyAppCrash("NPE in onCreate") + val state = QuickBuildSessionState.Building(3, warmingCompiler = true, pendingCrash = crash) + + assertThat(QuickBuildStatus.from(state)).isEqualTo(QuickBuildStatus.Failed(3, crash)) + } + + @Test + fun `deployed maps to up to date with the build duration`() { + val state = QuickBuildSessionState.Deployed(4, 900) + + assertThat(QuickBuildStatus.from(state)).isEqualTo(QuickBuildStatus.UpToDate(4, 900)) + } + + @Test + fun `restarted deploy maps to up to date with the restart flag - distinct surface`() { + val state = QuickBuildSessionState.Deployed(4, 900, restarted = true) + + assertThat(QuickBuildStatus.from(state)) + .isEqualTo(QuickBuildStatus.UpToDate(4, 900, restarted = true)) + } + + @Test + fun `invalidated maps to needs full build`() { + val state = QuickBuildSessionState.Invalidated(InvalidationReason.MANIFEST_CHANGED, 3) + + assertThat(QuickBuildStatus.from(state)) + .isEqualTo(QuickBuildStatus.NeedsFullBuild(InvalidationReason.MANIFEST_CHANGED, 3)) + } + + @Test + fun `an invalidated session awaiting retry carries that into the status`() { + // Without this the surface shows the ordinary "next build is full" bolt while a failed + // rebaseline sits parked waiting for the user to fix it by hand. + val state = + QuickBuildSessionState.Invalidated( + InvalidationReason.GRADLE_CONFIG_CHANGED, + 3, + awaitingRetry = true, + ) + + assertThat(QuickBuildStatus.from(state)) + .isEqualTo( + QuickBuildStatus.NeedsFullBuild( + InvalidationReason.GRADLE_CONFIG_CHANGED, + 3, + awaitingRetry = true, + ), + ) + } + + @Test + fun `degraded maps to reconnecting`() { + val state = QuickBuildSessionState.Degraded(3) + + assertThat(QuickBuildStatus.from(state)).isEqualTo(QuickBuildStatus.Reconnecting(3)) + } + + @Test + fun `a degraded session whose restart failed carries that into the status`() { + // Without this the surface says "compile daemon restarting" while nothing is restarting + // it, contradicting the snackbar that just said the restart failed. + val state = QuickBuildSessionState.Degraded(3, restartFailed = true) + + assertThat(QuickBuildStatus.from(state)) + .isEqualTo(QuickBuildStatus.Reconnecting(3, restartFailed = true)) + } + + @Test + fun `no state maps to a transient building status except Building`() { + val failure = + SessionFailure.CompileError( + listOf(BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "msg", "A.kt", 1, 1)), + ) + val nonBuildingStates = + listOf( + QuickBuildSessionState.Idle(), + QuickBuildSessionState.Provisioning(), + QuickBuildSessionState.Ready(3), + QuickBuildSessionState.Ready(3, lastFailure = failure), + QuickBuildSessionState.Building(3, warmingCompiler = true), + QuickBuildSessionState.Deployed(4, 900), + QuickBuildSessionState.Invalidated(InvalidationReason.MANIFEST_CHANGED, 3), + QuickBuildSessionState.Degraded(3), + ) + + nonBuildingStates.forEach { state -> + assertThat(QuickBuildStatus.from(state)) + .isNotInstanceOf(QuickBuildStatus.Building::class.java) + } + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildToneTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildToneTest.kt new file mode 100644 index 0000000000..abfefedc12 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildToneTest.kt @@ -0,0 +1,125 @@ +package org.appdevforall.cotg.quickbuild.domain.session + +import com.google.common.truth.Truth.assertThat +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.junit.jupiter.api.Test + +class QuickBuildToneTest { + @Test + fun `hidden and up-to-date map to READY`() { + assertThat(QuickBuildStatus.Hidden().toTone()).isEqualTo(QuickBuildTone.READY) + assertThat(QuickBuildStatus.UpToDate(1, null).toTone()).isEqualTo(QuickBuildTone.READY) + assertThat(QuickBuildStatus.UpToDate(1, 500).toTone()).isEqualTo(QuickBuildTone.READY) + } + + @Test + fun `provisioning and building map to BUILDING`() { + assertThat(QuickBuildStatus.Provisioning().toTone()).isEqualTo(QuickBuildTone.BUILDING) + assertThat(QuickBuildStatus.Building(1).toTone()).isEqualTo(QuickBuildTone.BUILDING) + } + + // Behaviour 1 draws a line: the button offers a stop for builds the USER started, and + // keeps the bolt for the two background builds they did not. These are the derivations + // that decide it, so they are pinned rather than left to be re-decided by accident. + @Test + fun `the background warm compile reads as READY - it deploys nothing and was never asked for`() { + val warmCompiling = QuickBuildSessionState.Building(3, warmingCompiler = true) + + assertThat(QuickBuildStatus.from(warmCompiling).toTone()).isEqualTo(QuickBuildTone.READY) + } + + @Test + fun `an unasked-for prebuild reads as READY, but a prebuild with a queued tap reads as BUILDING`() { + assertThat(QuickBuildStatus.from(QuickBuildSessionState.Prebuilding()).toTone()) + .isEqualTo(QuickBuildTone.READY) + // Once a tap is queued the user IS waiting on this build, so the stop belongs to them. + assertThat(QuickBuildStatus.from(QuickBuildSessionState.Prebuilding(tapQueued = true)).toTone()) + .isEqualTo(QuickBuildTone.BUILDING) + } + + @Test + fun `a real quick build reads as BUILDING so the button becomes the stop button`() { + assertThat(QuickBuildStatus.from(QuickBuildSessionState.Building(3)).toTone()) + .isEqualTo(QuickBuildTone.BUILDING) + } + + @Test + fun `only a real failure reads as ERROR`() { + val failure = SessionFailure.DeployError("boom") + assertThat(QuickBuildStatus.Failed(1, failure).toTone()).isEqualTo(QuickBuildTone.ERROR) + } + + @Test + fun `needing a full build is SLOW, not an error - it is ordinary work`() { + assertThat( + QuickBuildStatus.NeedsFullBuild(InvalidationReason.MANIFEST_CHANGED, 1).toTone(), + ).isEqualTo(QuickBuildTone.SLOW) + // End to end from the session state: a plain invalidation still reads as SLOW. + assertThat( + QuickBuildStatus + .from( + QuickBuildSessionState.Invalidated(InvalidationReason.MANIFEST_CHANGED, 1), + ).toTone(), + ).isEqualTo(QuickBuildTone.SLOW) + } + + @Test + fun `a rebaseline that failed and parked IS an error - only the user moves it`() { + // SLOW is documented as "not a failure", but a parked rebaseline is one: the manual QA + // bug was a failed rebaseline showing the same hollow bolt as ordinary upcoming work. + assertThat( + QuickBuildStatus + .from( + QuickBuildSessionState.Invalidated( + InvalidationReason.GRADLE_CONFIG_CHANGED, + 1, + awaitingRetry = true, + ), + ).toTone(), + ).isEqualTo(QuickBuildTone.ERROR) + } + + @Test + fun `a daemon respawn is RECONNECTING, not an error - it resolves itself`() { + assertThat(QuickBuildStatus.Reconnecting(1).toTone()).isEqualTo(QuickBuildTone.RECONNECTING) + } + + @Test + fun `a respawn that failed IS an error - it does not resolve itself`() { + // RECONNECTING is documented as transient work with nothing to do. After a failed + // respawn the compiler stays down until the user taps, which is what ERROR means. + assertThat(QuickBuildStatus.Reconnecting(1, restartFailed = true).toTone()) + .isEqualTo(QuickBuildTone.ERROR) + } + + /** + * The regression this split exists to prevent: three unlike states all rendering as the + * one red icon, so "something broke" was claimed far more often than anything had. + */ + @Test + fun `no status other than a failure claims the error tone`() { + val nonFailures = + listOf( + QuickBuildStatus.Hidden(), + QuickBuildStatus.UpToDate(1, null), + QuickBuildStatus.Provisioning(), + QuickBuildStatus.Building(1), + QuickBuildStatus.NeedsFullBuild(InvalidationReason.MANIFEST_CHANGED, 1), + QuickBuildStatus.Reconnecting(1), + ) + + nonFailures.forEach { status -> + assertThat(status.toTone()).isNotEqualTo(QuickBuildTone.ERROR) + } + } + + /** + * Only [QuickBuildTone.BUILDING] makes a tap cancel (QuickBuildAction.execAction keys off + * exactly this), so a state the user cannot cancel must never claim it - a tap in + * Reconnecting would otherwise dispatch CancelRequested with no build to cancel. + */ + @Test + fun `reconnecting does not claim the BUILDING tone, which would make a tap cancel`() { + assertThat(QuickBuildStatus.Reconnecting(1).toTone()).isNotEqualTo(QuickBuildTone.BUILDING) + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/session/SessionReducerTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/session/SessionReducerTest.kt new file mode 100644 index 0000000000..96521c95c1 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/session/SessionReducerTest.kt @@ -0,0 +1,1449 @@ +package org.appdevforall.cotg.quickbuild.domain.session + +import com.google.common.truth.Truth.assertThat +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage +import org.junit.jupiter.api.Test + +class SessionReducerTest { + private val reducer = SessionReducer() + + @Test + fun `idle plus QuickBuildTapped starts provisioning`() { + val transition = reducer.reduce(QuickBuildSessionState.Idle(), SessionEvent.QuickBuildTapped()) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.StartProvisioning)) + } + + @Test + fun `idle ignores a late BuildSucceeded event`() { + val transition = + reducer.reduce(QuickBuildSessionState.Idle(), SessionEvent.BuildSucceeded(3, 100)) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `provisioning succeeded becomes ready and starts the background warm compile`() { + val transition = + reducer.reduce(QuickBuildSessionState.Provisioning(), SessionEvent.ProvisioningSucceeded(1)) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Ready(1, lastFailure = null)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.StartWarmCompile)) + } + + @Test + fun `warm compile finished returns building to ready at the unchanged generation`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Building(deployedGeneration = 4, warmingCompiler = true), + SessionEvent.WarmCompileFinished, + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Ready(4, lastFailure = null)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `warm compile started moves ready into a warm-compiling building state`() { + val transition = + reducer.reduce(QuickBuildSessionState.Ready(4), SessionEvent.WarmCompileStarted) + + assertThat(transition.state) + .isEqualTo(QuickBuildSessionState.Building(4, warmingCompiler = true)) + assertThat(transition.effects).isEmpty() + } + + // A tap during a warm compile must not vanish - a warm compile deploys nothing, so nothing + // else will satisfy it. The orchestrator decides whether it builds (dirty tap) or just + // switches (clean tap); the reducer only routes it there. + @Test + fun `a tap during the warm compile triggers a build instead of being dropped`() { + val warmCompiling = QuickBuildSessionState.Building(4, warmingCompiler = true) + val transition = reducer.reduce(warmCompiling, SessionEvent.QuickBuildTapped()) + + assertThat(transition.state).isEqualTo(warmCompiling) + assertThat(transition.effects) + .isEqualTo(listOf(SessionEffect.TriggerLiveReload(userInitiated = true))) + } + + // A crash of the RUNNING generation during the warm-compile + // window must surface like it does outside it - the warm compile's silent-outcome contract + // covers warm-compile results, not crashes. + @Test + fun `a proxy-app crash during the warm compile is carried and surfaced when it finishes`() { + val warmCompiling = QuickBuildSessionState.Building(4, warmingCompiler = true) + val crashed = reducer.reduce(warmCompiling, SessionEvent.ProxyAppCrashed("NPE in onCreate")) + + assertThat(crashed.state) + .isEqualTo( + QuickBuildSessionState.Building( + 4, + warmingCompiler = true, + pendingCrash = SessionFailure.ProxyAppCrash("NPE in onCreate"), + ), + ) + assertThat(crashed.effects).isEmpty() + + val finished = reducer.reduce(crashed.state, SessionEvent.WarmCompileFinished) + + assertThat(finished.state) + .isEqualTo( + QuickBuildSessionState.Ready( + 4, + lastFailure = SessionFailure.ProxyAppCrash("NPE in onCreate"), + ), + ) + assertThat(finished.effects).isEmpty() + } + + @Test + fun `warm compile finished is a no-op outside building`() { + val ready = QuickBuildSessionState.Ready(2) + val transition = reducer.reduce(ready, SessionEvent.WarmCompileFinished) + + assertThat(transition.state).isEqualTo(ready) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `provisioning failed returns to idle and surfaces the error`() { + val transition = + reducer.reduce(QuickBuildSessionState.Provisioning(), SessionEvent.ProvisioningFailed(QuickBuildMessage.Literal("boom"))) + + // lastStartFailed keeps the error tone on the bolt until the next tap or save (Q8). + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Idle(lastStartFailed = true)) + assertThat(transition.effects) + .isEqualTo(listOf(SessionEffect.SurfaceProvisioningError(QuickBuildMessage.Literal("boom")))) + } + + @Test + fun `provisioning ignores a QuickBuildTapped event`() { + val transition = + reducer.reduce(QuickBuildSessionState.Provisioning(), SessionEvent.QuickBuildTapped()) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Provisioning()) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `ready plus QuickBuildTapped stays ready and triggers a build`() { + val transition = + reducer.reduce(QuickBuildSessionState.Ready(1), SessionEvent.QuickBuildTapped()) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Ready(1)) + assertThat(transition.effects) + .isEqualTo(listOf(SessionEffect.TriggerLiveReload(userInitiated = true))) + } + + @Test + fun `deployed plus QuickBuildTapped stays deployed and triggers a build`() { + // A tap on an already-deployed generation is the forced-redeploy path, so it must + // behave exactly like the Ready one. Ready and Deployed share a handler today; this + // pins the Deployed half so splitting them cannot silently drop the tap. + val transition = + reducer.reduce(QuickBuildSessionState.Deployed(2, 500), SessionEvent.QuickBuildTapped()) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Deployed(2, 500)) + assertThat(transition.effects) + .isEqualTo(listOf(SessionEffect.TriggerLiveReload(userInitiated = true))) + } + + // The tap's one bit (whether its save-all wrote anything) must reach the orchestrator, or + // a dirty-buffer tap would be treated as a do-nothing tap and switch before its build. + @Test + fun `a tap that wrote something carries the bit into the trigger effect - from every live state`() { + val liveStates = + listOf( + QuickBuildSessionState.Ready(1), + QuickBuildSessionState.Deployed(2, 500), + QuickBuildSessionState.Building(4, warmingCompiler = true), + ) + + for (state in liveStates) { + val transition = reducer.reduce(state, SessionEvent.QuickBuildTapped(wroteSomething = true)) + + assertThat(transition.state).isEqualTo(state) + assertThat(transition.effects) + .isEqualTo(listOf(SessionEffect.TriggerLiveReload(userInitiated = true, expectChanges = true))) + } + } + + // States that do not trigger a live reload ignore the bit: the tap means the same thing + // with or without a preceding write there, so the transitions must be identical. + @Test + fun `states that do not trigger a reload treat a clean and a dirty tap identically`() { + val states = + listOf( + QuickBuildSessionState.Idle(), + QuickBuildSessionState.Prebuilding(), + QuickBuildSessionState.Provisioning(), + QuickBuildSessionState.Building(3), + QuickBuildSessionState.Invalidated( + InvalidationReason.MANIFEST_CHANGED, + 1, + awaitingRetry = true, + ), + QuickBuildSessionState.Degraded(3), + ) + + for (state in states) { + val clean = reducer.reduce(state, SessionEvent.QuickBuildTapped(wroteSomething = false)) + val dirty = reducer.reduce(state, SessionEvent.QuickBuildTapped(wroteSomething = true)) + + assertThat(dirty.state).isEqualTo(clean.state) + assertThat(dirty.effects).isEqualTo(clean.effects) + } + } + + @Test + fun `ready plus BuildStarted moves to building`() { + val transition = + reducer.reduce(QuickBuildSessionState.Ready(1), SessionEvent.BuildStarted) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Building(1)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `deployed plus BuildStarted moves to building at the deployed generation`() { + val transition = + reducer.reduce(QuickBuildSessionState.Deployed(2, 500), SessionEvent.BuildStarted) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Building(2)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `building plus BuildSucceeded deploys the new generation`() { + val transition = + reducer.reduce(QuickBuildSessionState.Building(1), SessionEvent.BuildSucceeded(2, 800)) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Deployed(2, 800)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `building plus a restarted BuildSucceeded carries restarted into Deployed`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Building(1), + SessionEvent.BuildSucceeded(2, 800, restarted = true), + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Deployed(2, 800, restarted = true)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `building plus BuildFailed stays on the old generation with the failure recorded`() { + val failure = + SessionFailure.CompileError( + listOf(BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "msg", "A.kt", 1, 1)), + ) + + val transition = + reducer.reduce(QuickBuildSessionState.Building(1), SessionEvent.BuildFailed(failure)) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Ready(1, lastFailure = failure)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `building plus a deploy failure stays Ready - a failed relaunch retry must not tear the session down`() { + // Defect #88 tail: when the launch-and-retry-once recovery also fails, the + // outcome is a plain DeployFailure -> DeployError, and the session stays Ready + // so the user can relaunch the app and simply save again. + val failure = SessionFailure.DeployError("Proxy app is not connected. Relaunch your app to reconnect, then deploy again.") + + val transition = + reducer.reduce(QuickBuildSessionState.Building(1), SessionEvent.BuildFailed(failure)) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Ready(1, lastFailure = failure)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `building plus InvalidationDetected requires a full gradle proxy app rebuild`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Building(1), + SessionEvent.InvalidationDetected(InvalidationReason.MANIFEST_CHANGED), + ) + + assertThat(transition.state) + .isEqualTo(QuickBuildSessionState.Invalidated(InvalidationReason.MANIFEST_CHANGED, 1)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.RunProxyAppRebuild)) + } + + @Test + fun `ready plus InvalidationDetected requires a full gradle proxy app rebuild`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Ready(1), + SessionEvent.InvalidationDetected(InvalidationReason.GRADLE_CONFIG_CHANGED), + ) + + assertThat(transition.state) + .isEqualTo(QuickBuildSessionState.Invalidated(InvalidationReason.GRADLE_CONFIG_CHANGED, 1)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.RunProxyAppRebuild)) + } + + @Test + fun `invalidated plus ProxyAppRebuildStarted moves to provisioning carrying the reason`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Invalidated(InvalidationReason.MANIFEST_CHANGED, 1), + SessionEvent.ProxyAppRebuildStarted, + ) + + // The reason travels so the status surfaces can say "rebuilding your app" without + // having observed the Invalidated hop - which, on a conflating StateFlow, they usually + // have not. + assertThat(transition.state) + .isEqualTo( + QuickBuildSessionState.Provisioning(rebaselineReason = InvalidationReason.MANIFEST_CHANGED), + ) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `an unconfirmed proxy app rebuild install parks in invalidated awaiting retry - not idle`() { + // The stranded-session fix: the proxy app rebuild built fine, only the reinstall + // confirmation timed out. No effect fires (an automatic retry would re-prompt + // forever); the session waits for the user's tap instead of dying to Idle. + val transition = + reducer.reduce( + QuickBuildSessionState.Provisioning(), + SessionEvent.ProxyAppRebuildInstallNotConfirmed(deployedGeneration = 2), + ) + + assertThat(transition.state) + .isEqualTo( + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + 2, + awaitingRetry = true, + ), + ) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `a deferred proxy app rebuild retry parks back and gives its auto-retry back`() { + // The retry asks for the device's single Gradle slot; if CoGo's own project sync + // holds it, no build runs and no install is prompted. Charging the budget for that + // spends the one retry the park depends on and drops the session to Idle behind a + // "Proxy app rebuild failed" banner. + val transition = + reducer.reduce( + QuickBuildSessionState.Provisioning(installAutoRetries = 1), + SessionEvent.ProxyAppRebuildDeferred(deployedGeneration = 2), + ) + + assertThat(transition.state) + .isEqualTo( + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + 2, + awaitingRetry = true, + installAutoRetries = 0, + ), + ) + // No effect: retrying immediately would just hit the same busy slot. The next + // foreground return or tap runs it. + assertThat(transition.effects).isEmpty() + } + + @Test + fun `a deferred proxy app rebuild retry never drives the auto-retry count below zero`() { + // A TAP-initiated retry arrives with the budget already reset to 0, so the + // give-back has nothing to give. + val transition = + reducer.reduce( + QuickBuildSessionState.Provisioning(installAutoRetries = 0), + SessionEvent.ProxyAppRebuildDeferred(deployedGeneration = 7), + ) + + assertThat(transition.state) + .isEqualTo( + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + 7, + awaitingRetry = true, + installAutoRetries = 0, + ), + ) + } + + @Test + fun `deferrals do not lift the auto-retry cap - real attempts still bound it`() { + // The give-back must not become an unbounded budget: a deferral costs nothing, but + // the attempts that DO run a Gradle build still count up to the cap. + var state: QuickBuildSessionState = + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + 2, + awaitingRetry = true, + ) + + // One deferred attempt: parked again, budget untouched. + state = reducer.reduce(state, SessionEvent.HostForegrounded).state + state = reducer.reduce(state, SessionEvent.ProxyAppRebuildStarted).state + state = reducer.reduce(state, SessionEvent.ProxyAppRebuildDeferred(deployedGeneration = 2)).state + assertThat((state as QuickBuildSessionState.Invalidated).installAutoRetries).isEqualTo(0) + + // Then MAX real attempts, each ending unconfirmed: the budget fills up. + repeat(SessionReducer.MAX_INSTALL_AUTO_RETRIES) { + state = reducer.reduce(state, SessionEvent.HostForegrounded).state + state = reducer.reduce(state, SessionEvent.ProxyAppRebuildStarted).state + state = + reducer + .reduce(state, SessionEvent.ProxyAppRebuildInstallNotConfirmed(deployedGeneration = 2)) + .state + } + assertThat((state as QuickBuildSessionState.Invalidated).installAutoRetries) + .isEqualTo(SessionReducer.MAX_INSTALL_AUTO_RETRIES) + + // Capped: the next foreground return runs nothing and stays parked. + val exhausted = reducer.reduce(state, SessionEvent.HostForegrounded) + assertThat(exhausted.state).isEqualTo(state) + assertThat(exhausted.effects).isEmpty() + } + + @Test + fun `invalidated awaiting retry plus QuickBuildTapped retries the proxy app rebuild once`() { + val parked = + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + 2, + awaitingRetry = true, + ) + + val transition = reducer.reduce(parked, SessionEvent.QuickBuildTapped()) + + // awaitingRetry drops with the effect, so a second tap before ProxyAppRebuildStarted + // cannot double-run the Gradle build. The tap also asks to see the app - recorded here, + // held by the shell until the rebuild lands, and dropped if it does not. HostForegrounded + // (below) does not ask: nobody pressed anything, so it must not move the user. + assertThat(transition.state).isEqualTo(parked.copy(awaitingRetry = false)) + assertThat(transition.effects) + .isEqualTo(listOf(SessionEffect.RunProxyAppRebuild, SessionEffect.SwitchToProxyApp)) + } + + @Test + fun `invalidated awaiting retry plus HostForegrounded retries the proxy app rebuild once`() { + // The backgrounded-CoGo case: the reinstall ran with no dialog ever shown + // (Android defers PENDING_USER_ACTION until foreground, and the dialog-owning + // subscriber is lifecycle-bound), so the user's return to CoGo must re-prompt + // without requiring a tap they don't know to make. The retry spends one unit + // of the bounded auto-retry budget. + val parked = + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + 2, + awaitingRetry = true, + ) + + val transition = reducer.reduce(parked, SessionEvent.HostForegrounded) + + assertThat(transition.state) + .isEqualTo(parked.copy(awaitingRetry = false, installAutoRetries = 1)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.RunProxyAppRebuild)) + } + + @Test + fun `HostForegrounded stops auto-retrying once the budget is spent - stays parked`() { + // A user who keeps declining must not pay a fresh Gradle build on every + // resume, forever (defect #90). Past the cap the session just stays parked. + val exhausted = + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + 2, + awaitingRetry = true, + installAutoRetries = SessionReducer.MAX_INSTALL_AUTO_RETRIES, + ) + + val transition = reducer.reduce(exhausted, SessionEvent.HostForegrounded) + + assertThat(transition.state).isEqualTo(exhausted) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `a failed proxy app rebuild parks recoverable instead of dying to idle`() { + // The rebaseline defect from manual QA: a broken build file (compileSdk the device has + // no platform for) dropped the session to Idle, so a source compile error was + // recoverable while a Gradle config error was terminal. + val transition = + reducer.reduce( + QuickBuildSessionState.Provisioning(), + SessionEvent.ProxyAppRebuildFailed( + InvalidationReason.GRADLE_CONFIG_CHANGED, + deployedGeneration = 3, + ), + ) + + assertThat(transition.state) + .isEqualTo( + QuickBuildSessionState.Invalidated( + InvalidationReason.GRADLE_CONFIG_CHANGED, + 3, + awaitingRetry = true, + ), + ) + // No effect: SurfaceProvisioningError would tear the session down, and an automatic + // retry would just rebuild the same broken file. + assertThat(transition.effects).isEmpty() + } + + @Test + fun `a failed proxy app rebuild CARRIES the auto-retry count - it does not refund it`() { + // A build file the user has not fixed must not buy a fresh budget of Gradle builds on + // every return to the editor. + val transition = + reducer.reduce( + QuickBuildSessionState.Provisioning(installAutoRetries = 2), + SessionEvent.ProxyAppRebuildFailed( + InvalidationReason.GRADLE_CONFIG_CHANGED, + deployedGeneration = 3, + ), + ) + + assertThat((transition.state as QuickBuildSessionState.Invalidated).installAutoRetries) + .isEqualTo(2) + } + + @Test + fun `saving the fix for a failed rebuild retries it - the user never leaves the editor`() { + // The other half of the rebaseline defect: reverting the bad compileSdk left the + // session stuck, because the park only listened for a tap or a foreground return and + // neither was coming - the user stayed in the editor the whole time. The save IS the + // recovery gesture. + val parked = + QuickBuildSessionState.Invalidated( + InvalidationReason.GRADLE_CONFIG_CHANGED, + 3, + awaitingRetry = true, + installAutoRetries = 2, + ) + + val transition = + reducer.reduce( + parked, + SessionEvent.InvalidationDetected(InvalidationReason.GRADLE_CONFIG_CHANGED), + ) + + // A changed file is a genuinely new attempt, so the budget resets; awaitingRetry drops + // with the effect so a second save cannot double-run the Gradle build. + assertThat(transition.state).isEqualTo(parked.copy(awaitingRetry = false, installAutoRetries = 0)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.RunProxyAppRebuild)) + } + + @Test + fun `a save while a proxy app rebuild is in flight does not start a second one`() { + val inFlight = + QuickBuildSessionState.Invalidated( + InvalidationReason.GRADLE_CONFIG_CHANGED, + 3, + awaitingRetry = false, + ) + + val transition = + reducer.reduce( + inFlight, + SessionEvent.InvalidationDetected(InvalidationReason.MANIFEST_CHANGED), + ) + + assertThat(transition.state).isEqualTo(inFlight) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `a Quick Build tap retries even with the auto-retry budget spent and re-arms it`() { + // An explicit tap is fresh consent: it always re-prompts and resets the + // HostForegrounded budget. + val exhausted = + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + 2, + awaitingRetry = true, + installAutoRetries = SessionReducer.MAX_INSTALL_AUTO_RETRIES, + ) + + val transition = reducer.reduce(exhausted, SessionEvent.QuickBuildTapped()) + + assertThat(transition.state) + .isEqualTo(exhausted.copy(awaitingRetry = false, installAutoRetries = 0)) + // The tap is also a request to see the app, recorded here and held by the shell until + // the rebuild lands - a rebaseline must not hand the user their stale app mid-build. + assertThat(transition.effects) + .isEqualTo(listOf(SessionEffect.RunProxyAppRebuild, SessionEffect.SwitchToProxyApp)) + } + + @Test + fun `the auto-retry count survives the park - retry - park round trip`() { + // The budget is per unconfirmed install, not per park: it rides Invalidated -> + // Provisioning (ProxyAppRebuildStarted) -> Invalidated (ProxyAppRebuildInstallNotConfirmed). + // Without the carry, every park would reset the count and the cap could never + // be reached. + val parked = + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + 2, + awaitingRetry = true, + ) + + val retried = reducer.reduce(parked, SessionEvent.HostForegrounded) + val provisioning = reducer.reduce(retried.state, SessionEvent.ProxyAppRebuildStarted) + assertThat(provisioning.state) + .isEqualTo( + QuickBuildSessionState.Provisioning( + installAutoRetries = 1, + rebaselineReason = InvalidationReason.INSTALL_NOT_CONFIRMED, + ), + ) + + val reParked = + reducer.reduce( + provisioning.state, + SessionEvent.ProxyAppRebuildInstallNotConfirmed(deployedGeneration = 2), + ) + assertThat(reParked.state) + .isEqualTo( + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + 2, + awaitingRetry = true, + installAutoRetries = 1, + ), + ) + + // The second foreground return spends the last unit; the third does nothing. + val secondRetry = reducer.reduce(reParked.state, SessionEvent.HostForegrounded) + assertThat(secondRetry.effects).isEqualTo(listOf(SessionEffect.RunProxyAppRebuild)) + val secondProvisioning = reducer.reduce(secondRetry.state, SessionEvent.ProxyAppRebuildStarted) + val secondPark = + reducer.reduce( + secondProvisioning.state, + SessionEvent.ProxyAppRebuildInstallNotConfirmed(deployedGeneration = 2), + ) + val thirdAttempt = reducer.reduce(secondPark.state, SessionEvent.HostForegrounded) + assertThat(thirdAttempt.state).isEqualTo(secondPark.state) + assertThat(thirdAttempt.effects).isEmpty() + } + + @Test + fun `invalidated with a proxy app rebuild in flight ignores HostForegrounded`() { + // After the retry fires (awaitingRetry dropped), a second onResume - e.g. the + // user dismissing the re-prompted install dialog - must not double-run Gradle. + val inFlight = + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + 2, + awaitingRetry = false, + ) + + val transition = reducer.reduce(inFlight, SessionEvent.HostForegrounded) + + assertThat(transition.state).isEqualTo(inFlight) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `HostForegrounded is a no-op in non-parked states`() { + for (state in listOf( + QuickBuildSessionState.Idle(), + QuickBuildSessionState.Provisioning(), + QuickBuildSessionState.Ready(1), + QuickBuildSessionState.Building(1), + QuickBuildSessionState.Deployed(1, buildDurationMillis = 100), + )) { + val transition = reducer.reduce(state, SessionEvent.HostForegrounded) + assertThat(transition.state).isEqualTo(state) + assertThat(transition.effects).isEmpty() + } + } + + @Test + fun `invalidated with a proxy app rebuild in flight ignores QuickBuildTapped`() { + val invalidated = QuickBuildSessionState.Invalidated(InvalidationReason.MANIFEST_CHANGED, 1) + + val transition = reducer.reduce(invalidated, SessionEvent.QuickBuildTapped()) + + assertThat(transition.state).isEqualTo(invalidated) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `retried proxy app rebuild start moves the parked session to provisioning`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Invalidated(InvalidationReason.INSTALL_NOT_CONFIRMED, 2), + SessionEvent.ProxyAppRebuildStarted, + ) + + assertThat(transition.state) + .isEqualTo( + QuickBuildSessionState.Provisioning(rebaselineReason = InvalidationReason.INSTALL_NOT_CONFIRMED), + ) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `invalidated awaiting retry plus SessionRestartRequested still tears down`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + 2, + awaitingRetry = true, + ), + SessionEvent.SessionRestartRequested, + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.TeardownSession)) + } + + @Test + fun `ready plus DaemonDied degrades and respawns`() { + val transition = reducer.reduce(QuickBuildSessionState.Ready(1), SessionEvent.DaemonDied) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Degraded(1)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.RespawnDaemon)) + } + + @Test + fun `building plus DaemonDied degrades and respawns`() { + val transition = reducer.reduce(QuickBuildSessionState.Building(1), SessionEvent.DaemonDied) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Degraded(1)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.RespawnDaemon)) + } + + @Test + fun `degraded plus DaemonRespawned returns to ready`() { + val transition = + reducer.reduce(QuickBuildSessionState.Degraded(1), SessionEvent.DaemonRespawned) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Ready(1)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `degraded plus DaemonDied stays degraded without a duplicate respawn effect`() { + val transition = reducer.reduce(QuickBuildSessionState.Degraded(1), SessionEvent.DaemonDied) + + // Still no auto-retry - that is deliberate, and the no-spin property. What changed is + // that the state stops claiming a restart is under way, since nothing is running one. + assertThat(transition.state) + .isEqualTo(QuickBuildSessionState.Degraded(1, restartFailed = true)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `degraded plus DaemonRestartFailed records the failure and schedules nothing`() { + val transition = + reducer.reduce(QuickBuildSessionState.Degraded(1), SessionEvent.DaemonRestartFailed) + + assertThat(transition.state) + .isEqualTo(QuickBuildSessionState.Degraded(1, restartFailed = true)) + assertThat(transition.effects).isEmpty() + // The status is the whole point of the flag: "restarting" was a claim about work that + // had already failed. + assertThat(QuickBuildStatus.from(transition.state)) + .isEqualTo(QuickBuildStatus.Reconnecting(1, restartFailed = true)) + } + + @Test + fun `a failed restart is not undone by a stale DaemonRespawned for the daemon that died`() { + // The second-death race: the respawned child died between start() returning Ok and this + // event landing, so its death was recorded first. Going Ready here would announce a live + // compiler that is already gone. + val transition = + reducer.reduce( + QuickBuildSessionState.Degraded(1, restartFailed = true), + SessionEvent.DaemonRespawned, + ) + + assertThat(transition.state) + .isEqualTo(QuickBuildSessionState.Degraded(1, restartFailed = true)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `a tap after a failed restart clears the flag, so the status is honest again`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Degraded(3, restartFailed = true), + SessionEvent.QuickBuildTapped(), + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Degraded(3)) + assertThat(transition.effects) + .isEqualTo( + listOf( + SessionEffect.SurfaceMessage(QuickBuildMessage.DaemonRestartRetrying), + SessionEffect.RespawnDaemon, + ), + ) + // And a respawn that now succeeds is believed again. + assertThat(reducer.reduce(transition.state, SessionEvent.DaemonRespawned).state) + .isEqualTo(QuickBuildSessionState.Ready(3)) + } + + @Test + fun `only degraded acts on DaemonRestartFailed`() { + // It records a fact about a respawn, and no other state has one in flight. + val states = + listOf( + QuickBuildSessionState.Ready(1), + QuickBuildSessionState.Building(1), + QuickBuildSessionState.Deployed(1, 5), + QuickBuildSessionState.Invalidated(InvalidationReason.MANIFEST_CHANGED, 1), + QuickBuildSessionState.Idle(), + ) + + for (state in states) { + val transition = reducer.reduce(state, SessionEvent.DaemonRestartFailed) + + assertThat(transition.state).isEqualTo(state) + assertThat(transition.effects).isEmpty() + } + } + + @Test + fun `deployed plus ProxyAppCrashed falls back to ready with the crash recorded`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Deployed(2, 500), + SessionEvent.ProxyAppCrashed("NPE in onCreate"), + ) + + assertThat(transition.state) + .isEqualTo( + QuickBuildSessionState.Ready( + 2, + lastFailure = SessionFailure.ProxyAppCrash("NPE in onCreate"), + ), + ) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `building plus ProxyAppCrashed stays building while the next build runs`() { + val transition = + reducer.reduce(QuickBuildSessionState.Building(1), SessionEvent.ProxyAppCrashed("crash")) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Building(1)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `idle plus PrebuildRequested starts the eager proxy app build`() { + val transition = reducer.reduce(QuickBuildSessionState.Idle(), SessionEvent.PrebuildRequested) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Prebuilding(tapQueued = false)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.StartProxyAppPrebuild)) + } + + @Test + fun `prebuilding finished without a tap returns to idle - install is deferred`() { + val transition = + reducer.reduce(QuickBuildSessionState.Prebuilding(), SessionEvent.PrebuildFinished) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `tap during prebuilding queues instead of racing the warm build`() { + val transition = + reducer.reduce(QuickBuildSessionState.Prebuilding(), SessionEvent.QuickBuildTapped()) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Prebuilding(tapQueued = true)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `prebuilding finished with a queued tap starts provisioning`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Prebuilding(tapQueued = true), + SessionEvent.PrebuildFinished, + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.StartProvisioning)) + } + + @Test + fun `prebuild requested while a session is live is a no-op`() { + val transition = + reducer.reduce(QuickBuildSessionState.Ready(2), SessionEvent.PrebuildRequested) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Ready(2)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `prebuild requested while prebuilding does not start a second warm build`() { + val transition = + reducer.reduce(QuickBuildSessionState.Prebuilding(), SessionEvent.PrebuildRequested) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Prebuilding()) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `ready plus ExternalBuildCompleted stays ready and refreshes the baseline`() { + val transition = + reducer.reduce(QuickBuildSessionState.Ready(2), SessionEvent.ExternalBuildCompleted) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Ready(2)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.RefreshBaseline)) + } + + @Test + fun `deployed plus ExternalBuildCompleted refreshes the baseline`() { + val transition = + reducer.reduce(QuickBuildSessionState.Deployed(3, 700), SessionEvent.ExternalBuildCompleted) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Deployed(3, 700)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.RefreshBaseline)) + } + + @Test + fun `building plus ExternalBuildCompleted coalesces the refresh into the follow-up build`() { + val transition = + reducer.reduce(QuickBuildSessionState.Building(1), SessionEvent.ExternalBuildCompleted) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Building(1)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.RefreshBaseline)) + } + + @Test + fun `degraded plus ExternalBuildCompleted refreshes the baseline`() { + val transition = + reducer.reduce(QuickBuildSessionState.Degraded(1), SessionEvent.ExternalBuildCompleted) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Degraded(1)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.RefreshBaseline)) + } + + @Test + fun `idle plus ExternalBuildCompleted does nothing - no session to refresh`() { + val transition = + reducer.reduce(QuickBuildSessionState.Idle(), SessionEvent.ExternalBuildCompleted) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `invalidated plus ExternalBuildCompleted does nothing - the proxy app rebuild absorbs it`() { + val invalidated = QuickBuildSessionState.Invalidated(InvalidationReason.MANIFEST_CHANGED, 1) + val transition = reducer.reduce(invalidated, SessionEvent.ExternalBuildCompleted) + + assertThat(transition.state).isEqualTo(invalidated) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `degraded plus InvalidationDetected proxy app rebuilds instead of stranding the session`() { + // Regression: the orchestrator reports an invalidation ONCE. Dropping it while + // Degraded meant no proxy app rebuild would ever run and no build could ever start again. + val transition = + reducer.reduce( + QuickBuildSessionState.Degraded(1), + SessionEvent.InvalidationDetected(InvalidationReason.GRADLE_CONFIG_CHANGED), + ) + + assertThat(transition.state) + .isEqualTo(QuickBuildSessionState.Invalidated(InvalidationReason.GRADLE_CONFIG_CHANGED, 1)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.RunProxyAppRebuild)) + } + + @Test + fun `idle plus SessionRestartRequested is a no-op - nothing to tear down`() { + val transition = + reducer.reduce(QuickBuildSessionState.Idle(), SessionEvent.SessionRestartRequested) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `ready plus SessionRestartRequested tears down and returns to idle`() { + val transition = + reducer.reduce(QuickBuildSessionState.Ready(3), SessionEvent.SessionRestartRequested) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.TeardownSession)) + } + + @Test + fun `building plus SessionRestartRequested tears down mid-build`() { + val transition = + reducer.reduce(QuickBuildSessionState.Building(1), SessionEvent.SessionRestartRequested) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.TeardownSession)) + } + + @Test + fun `degraded plus SessionRestartRequested tears down instead of waiting on a respawn`() { + val transition = + reducer.reduce(QuickBuildSessionState.Degraded(1), SessionEvent.SessionRestartRequested) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.TeardownSession)) + } + + @Test + fun `prebuilding plus SessionRestartRequested tears down the warm-up`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Prebuilding(tapQueued = true), + SessionEvent.SessionRestartRequested, + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.TeardownSession)) + } + + // The user-facing restart (T15). Resting at Idle is what made the menu item read as dead: + // Hidden and a settled session share the READY tone, so nothing on screen changed, and the + // fresh proxy app build that three notices name as the remedy never ran. + + @Test + fun `ready plus SessionRestartAndReprovisionRequested tears down and provisions in one step`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Ready(3), + SessionEvent.SessionRestartAndReprovisionRequested, + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.TeardownAndProvision)) + } + + @Test + fun `idle plus SessionRestartAndReprovisionRequested provisions with nothing to tear down`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Idle(), + SessionEvent.SessionRestartAndReprovisionRequested, + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.StartProvisioning)) + } + + @Test + fun `deployed plus SessionRestartAndReprovisionRequested restarts rather than resting at idle`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Deployed(4, 900), + SessionEvent.SessionRestartAndReprovisionRequested, + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.TeardownAndProvision)) + } + + @Test + fun `building plus SessionRestartAndReprovisionRequested restarts mid-build`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Building(1), + SessionEvent.SessionRestartAndReprovisionRequested, + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.TeardownAndProvision)) + } + + @Test + fun `degraded plus SessionRestartAndReprovisionRequested restarts instead of waiting on a respawn`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Degraded(1), + SessionEvent.SessionRestartAndReprovisionRequested, + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.TeardownAndProvision)) + } + + @Test + fun `invalidated plus SessionRestartAndReprovisionRequested restarts rather than rebaselining`() { + // The restart is a fresh proxy app build from scratch, not the invalidated session's + // rebaseline, so it must not carry the rebaseline reason into Provisioning - the + // surfaces would then narrate it as "rebuilding because ". + val transition = + reducer.reduce( + QuickBuildSessionState.Invalidated(InvalidationReason.MANIFEST_CHANGED, 2), + SessionEvent.SessionRestartAndReprovisionRequested, + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) + assertThat((transition.state as QuickBuildSessionState.Provisioning).rebaselineReason).isNull() + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.TeardownAndProvision)) + } + + // Bryan's button spec (2026-07-29). The reducer owns two of the five decisions: WHO the + // proxy app is brought forward for (behaviours 2/3), and what a stop does per state + // (behaviour 5). The other three are shape/timing and live in the shell and the action. + + @Test + fun `a user-initiated provision brings the proxy app forward when the session goes live`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Provisioning(userInitiated = true), + SessionEvent.ProvisioningSucceeded(1), + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Ready(1)) + assertThat(transition.effects) + .isEqualTo(listOf(SessionEffect.StartWarmCompile, SessionEffect.SwitchToProxyApp)) + } + + @Test + fun `a proxy app rebuild going live leaves the user in the editor`() { + // Provisioning is also the proxy app rebuild's state, and a plain save can trigger one: + // finishing a minute-long Gradle build is not an answer to anything the user asked. + val transition = + reducer.reduce(QuickBuildSessionState.Provisioning(), SessionEvent.ProvisioningSucceeded(1)) + + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.StartWarmCompile)) + } + + @Test + fun `a deploy the user asked for switches to the proxy app`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Building(1), + SessionEvent.BuildSucceeded(2, 800, userInitiated = true), + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Deployed(2, 800)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.SwitchToProxyApp)) + } + + @Test + fun `a deploy a file write triggered leaves the user in the editor`() { + // Behaviour 3: the same successful deploy, with nobody having asked for it. + val transition = + reducer.reduce(QuickBuildSessionState.Building(1), SessionEvent.BuildSucceeded(2, 800)) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Deployed(2, 800)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `a tap during a real build records the ask without forcing a second build`() { + // The in-flight build deploys anyway, so the tap needs no build of its own - but + // dropping it outright means a tap landing on a save-triggered build does nothing + // the user can see. Mark, don't trigger: a second forced build behind one that + // already deploys is a full recompile for nothing. + val transition = + reducer.reduce(QuickBuildSessionState.Building(3), SessionEvent.QuickBuildTapped()) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Building(3)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.MarkBuildUserInitiated)) + } + + @Test + fun `stopping a real build returns to ready with no failure recorded`() { + val transition = + reducer.reduce(QuickBuildSessionState.Building(4), SessionEvent.CancelRequested) + + // Ready at the generation the proxy app still runs, lastFailure null: a cancellation + // the user chose must not render as the ATTENTION icon a broken build gets. + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Ready(4, lastFailure = null)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.CancelLiveReload)) + } + + @Test + fun `stopping does nothing during the background warm compile`() { + val warmCompiling = QuickBuildSessionState.Building(4, warmingCompiler = true) + + val transition = reducer.reduce(warmCompiling, SessionEvent.CancelRequested) + + assertThat(transition.state).isEqualTo(warmCompiling) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `stopping during provisioning cancels the Gradle proxy app build and tears down`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Provisioning(userInitiated = true), + SessionEvent.CancelRequested, + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Idle()) + // Order matters: the Gradle build has to be cancelled BEFORE the teardown cancels the + // coroutine that is awaiting it, or nothing would ever reach the cancellation token. + assertThat(transition.effects) + .isEqualTo(listOf(SessionEffect.CancelProxyAppBuild, SessionEffect.TeardownSession)) + } + + @Test + fun `stopping a queued tap during prebuild drops the tap and cancels the proxy app build`() { + val transition = + reducer.reduce( + QuickBuildSessionState.Prebuilding(tapQueued = true), + SessionEvent.CancelRequested, + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.CancelProxyAppBuild)) + } + + @Test + fun `stopping is a no-op in every state that does not own a build the user asked for`() { + // The button only shows the stop affordance in the states above, but the shell + // dispatches without checking - so every other state has to absorb it silently + // rather than, say, tearing a live session down. + for (state in listOf( + QuickBuildSessionState.Idle(), + QuickBuildSessionState.Prebuilding(tapQueued = false), + QuickBuildSessionState.Ready(1), + QuickBuildSessionState.Deployed(1, buildDurationMillis = 100), + QuickBuildSessionState.Invalidated(InvalidationReason.MANIFEST_CHANGED, 1), + QuickBuildSessionState.Degraded(1), + )) { + val transition = reducer.reduce(state, SessionEvent.CancelRequested) + assertThat(transition.state).isEqualTo(state) + assertThat(transition.effects).isEmpty() + } + } + + // ADFA-4128 known issue #89 and the blocker it belongs to: reduceInvalidated and + // reduceDegraded each ended in a silent `else` that swallowed events changing what the user + // can do, so a session could reach a state where every save and every tap produced no build, + // no message and no state change - "I saved my fix and nothing happened". + + @Test + fun `degraded plus QuickBuildTapped retries the respawn and says so`() { + // Catches: dropping QuickBuildTapped from reduceDegraded, or emitting RespawnDaemon with + // no acknowledgement. A respawn already in flight answers with Superseded and reports + // nothing, so the effect alone can still leave the tap looking ignored. + val transition = reducer.reduce(QuickBuildSessionState.Degraded(3), SessionEvent.QuickBuildTapped()) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Degraded(3)) + assertThat(transition.effects) + .isEqualTo( + listOf( + SessionEffect.SurfaceMessage(QuickBuildMessage.DaemonRestartRetrying), + SessionEffect.RespawnDaemon, + ), + ) + } + + @Test + fun `degraded plus BuildStarted narrates the save's build instead of dropping it`() { + // Catches: dropping BuildStarted from reduceDegraded. The watcher never stops, so a save + // while the compiler is down still starts a build; staying Degraded left it invisible. + val transition = reducer.reduce(QuickBuildSessionState.Degraded(3), SessionEvent.BuildStarted) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Building(3)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `degraded plus BuildSucceeded reports the deploy that landed`() { + // Catches: dropping BuildSucceeded from reduceDegraded. The daemon death listener can fire + // mid-build, so a build can land while the session sits here; reporting the old generation + // would be a lie the status surface carries until the next build. + val transition = + reducer.reduce( + QuickBuildSessionState.Degraded(3), + SessionEvent.BuildSucceeded(4, 900, restarted = false, userInitiated = true), + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Deployed(4, 900)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.SwitchToProxyApp)) + } + + @Test + fun `degraded plus BuildFailed surfaces the failure at the unchanged generation`() { + // Catches: dropping BuildFailed from reduceDegraded, or losing the diagnostics. A build + // that reported diagnostics reached a working compiler, so Ready is honest. + val failure = + SessionFailure.CompileError( + listOf(BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "unresolved reference", "A.kt", 3, 1)), + ) + + val transition = + reducer.reduce(QuickBuildSessionState.Degraded(3), SessionEvent.BuildFailed(failure)) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Ready(3, failure)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `degraded ignores a warm compile so the respawn keeps the status`() { + // Catches: routing WarmCompileStarted through Building, which would swap "restarting the + // compiler" for "up to date" while the daemon is still down. Deliberate no-op. + val started = + reducer.reduce(QuickBuildSessionState.Degraded(3), SessionEvent.WarmCompileStarted) + val finished = + reducer.reduce(QuickBuildSessionState.Degraded(3), SessionEvent.WarmCompileFinished) + + assertThat(started.state).isEqualTo(QuickBuildSessionState.Degraded(3)) + assertThat(started.effects).isEmpty() + assertThat(finished.state).isEqualTo(QuickBuildSessionState.Degraded(3)) + assertThat(finished.effects).isEmpty() + } + + @Test + fun `degraded plus ProxyAppCrashed keeps the respawn status - the notice carries the crash`() { + // Catches: replacing the Reconnecting status with the crash. The manager flashes + // RELOAD_CRASHED on every crash before dispatching this, so the user is told either way, + // and a dead compiler is the more urgent of the two. Deliberate no-op. + val transition = + reducer.reduce(QuickBuildSessionState.Degraded(3), SessionEvent.ProxyAppCrashed("NPE")) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Degraded(3)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `invalidated awaiting retry plus BuildStarted narrates the build`() { + // Catches: dropping BuildStarted from reduceInvalidated. A failed proxy app rebuild clears + // the orchestrator's absorption gate, so a save it judges absorbable really does start a + // quick build - and without narrating it the session reports "a full build is needed" + // throughout. + val parked = + QuickBuildSessionState.Invalidated( + InvalidationReason.EXTERNAL_FULL_BUILD, + 2, + awaitingRetry = true, + ) + + val transition = reducer.reduce(parked, SessionEvent.BuildStarted) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Building(2)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `invalidated awaiting retry plus BuildSucceeded reports the deploy`() { + // Catches: dropping BuildSucceeded from reduceInvalidated. Reached when the park and the + // build raced, so no BuildStarted arrived here. + val parked = + QuickBuildSessionState.Invalidated( + InvalidationReason.EXTERNAL_FULL_BUILD, + 2, + awaitingRetry = true, + ) + + val transition = reducer.reduce(parked, SessionEvent.BuildSucceeded(3, 700)) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Deployed(3, 700)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `invalidated awaiting retry plus BuildFailed surfaces the failure`() { + // Catches: dropping BuildFailed from reduceInvalidated, which left a compile error the + // user could fix in seconds invisible. + val parked = + QuickBuildSessionState.Invalidated( + InvalidationReason.EXTERNAL_FULL_BUILD, + 2, + awaitingRetry = true, + ) + val failure = SessionFailure.DeployError("not connected") + + val transition = reducer.reduce(parked, SessionEvent.BuildFailed(failure)) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Ready(2, failure)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `invalidated with a proxy app rebuild in flight keeps the park through build events`() { + // The other half of the awaitingRetry gate, and the reason it exists: with a rebuild in + // flight, ProxyAppRebuildStarted still has to land here to move the session to + // Provisioning. Catches a fix that narrates build events unconditionally - that would + // leave a multi-minute Gradle build reading as "up to date" with the rebuild hop dropped. + val rebuilding = QuickBuildSessionState.Invalidated(InvalidationReason.GRADLE_CONFIG_CHANGED, 2) + + val started = reducer.reduce(rebuilding, SessionEvent.BuildStarted) + val succeeded = reducer.reduce(rebuilding, SessionEvent.BuildSucceeded(3, 700)) + val failed = + reducer.reduce(rebuilding, SessionEvent.BuildFailed(SessionFailure.DeployError("boom"))) + + assertThat(started.state).isEqualTo(rebuilding) + assertThat(succeeded.state).isEqualTo(rebuilding) + assertThat(failed.state).isEqualTo(rebuilding) + assertThat(started.effects).isEmpty() + assertThat(succeeded.effects).isEmpty() + assertThat(failed.effects).isEmpty() + } + + @Test + fun `invalidated awaiting retry plus DaemonDied respawns without leaving the park`() { + // Catches: dropping DaemonDied from reduceInvalidated (every later save's quick build then + // dies on a dead compiler and nothing ever moves again), and equally catches "fixing" it by + // moving to Degraded, which would drop the reason and the retry the park exists to hold. + val parked = + QuickBuildSessionState.Invalidated( + InvalidationReason.GRADLE_CONFIG_CHANGED, + 2, + awaitingRetry = true, + ) + + val transition = reducer.reduce(parked, SessionEvent.DaemonDied) + + assertThat(transition.state).isEqualTo(parked) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.RespawnDaemon)) + } + + @Test + fun `invalidated with a proxy app rebuild in flight does not respawn on DaemonDied`() { + // The rebuild restarts the daemon itself (ProxyAppRebuildResult.DaemonRestartFailed), so a + // respawn issued here would race it. Catches an unconditional respawn. + val rebuilding = QuickBuildSessionState.Invalidated(InvalidationReason.GRADLE_CONFIG_CHANGED, 2) + + val transition = reducer.reduce(rebuilding, SessionEvent.DaemonDied) + + assertThat(transition.state).isEqualTo(rebuilding) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `invalidated ignores DaemonRespawned, warm compiles and crashes - the park outranks them`() { + // Deliberate no-ops. Catches a fix that clears the park on any of them: a working compiler + // does not make a stale baseline fresh, a warm compile deploys nothing, and a crash is + // already flashed as RELOAD_CRASHED by the manager. + val parked = + QuickBuildSessionState.Invalidated( + InvalidationReason.GRADLE_CONFIG_CHANGED, + 2, + awaitingRetry = true, + ) + + val ignored = + listOf( + SessionEvent.DaemonRespawned, + SessionEvent.WarmCompileStarted, + SessionEvent.WarmCompileFinished, + SessionEvent.ProxyAppCrashed("NPE"), + ) + + ignored.forEach { event -> + val transition = reducer.reduce(parked, event) + assertThat(transition.state).isEqualTo(parked) + assertThat(transition.effects).isEmpty() + } + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt index c794d9edab..eb50201192 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt @@ -11,6 +11,7 @@ import org.appdevforall.cotg.quickbuild.data.RelinkOutput import org.appdevforall.cotg.quickbuild.domain.reload.GenerationStore import org.appdevforall.cotg.quickbuild.service.deploy.DeployResult import org.appdevforall.cotg.quickbuild.service.deploy.DeploySender +import org.appdevforall.cotg.quickbuild.service.session.QuickBuildHistoryStore import java.io.File /** Scripted [QuickBuildDaemon]: every op records its arguments and replies per script. */ @@ -208,3 +209,30 @@ class FakePaths( override fun daemonEnvironment(): Map = emptyMap() } + +/** + * In-memory [QuickBuildHistoryStore]. Defaults to `hasUsedQuickBuild = true` (the "warm + * path") so the many [QuickBuildSessionManagerTest] cases exercising prebuild/tap + * mechanics don't need to touch the gate; tests of the gate itself flip it to false. + */ +class FakeQuickBuildHistoryStore : QuickBuildHistoryStore { + private var used = true + + /** + * Thrown by [setHasUsedQuickBuild] when set. Stands in for any real store failure + * (no project open, unwritable preferences): recording history is bookkeeping and must + * never be able to swallow the tap that triggered it. + */ + var writeError: Throwable? = null + + /** Runs on every [setHasUsedQuickBuild], so a test can observe WHEN the write lands. */ + var onWrite: () -> Unit = {} + + override fun hasUsedQuickBuild(): Boolean = used + + override fun setHasUsedQuickBuild(used: Boolean) { + onWrite() + writeError?.let { throw it } + this.used = used + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerEdgeTest.kt new file mode 100644 index 0000000000..ed1fd17b43 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerEdgeTest.kt @@ -0,0 +1,183 @@ +package org.appdevforall.cotg.quickbuild.service.deploy + +import com.google.common.truth.Truth.assertThat +import com.google.gson.JsonParser +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.data.CompileOutput +import org.appdevforall.cotg.quickbuild.data.DaemonReply +import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.reload.BuildRequest +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentInfo +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind +import org.appdevforall.cotg.quickbuild.domain.reload.DeployPolicy +import org.appdevforall.cotg.quickbuild.domain.reload.GenerationTracker +import org.appdevforall.cotg.quickbuild.service.FakeDaemon +import org.appdevforall.cotg.quickbuild.service.FakeDeploy +import org.appdevforall.cotg.quickbuild.service.MemoryGenerationStore +import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppLauncher +import org.appdevforall.cotg.quickbuild.service.session.LiveReloadExecutorImpl +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** + * Restart-path failure corners of [PayloadDeployer], driven through the real + * [LiveReloadExecutorImpl] like [LiveReloadExecutorImplTest]'s restart cases: the + * relaunch preconditions (no launcher wired / no package known), the failure verdicts + * a restart deploy can come back with, and restart metadata carrying changed assets. + */ +class PayloadDeployerEdgeTest { + @TempDir lateinit var projectRoot: File + + private val daemon = FakeDaemon() + private val deploy = FakeDeploy() + private val store = MemoryGenerationStore() + + private lateinit var tracker: GenerationTracker + private lateinit var sourceFile: File + private lateinit var assetFile: File + + @BeforeEach + fun setUp() { + val mainDir = File(projectRoot, "app/src/main") + sourceFile = + File(mainDir, "java/com/example/SyncService.kt").apply { + parentFile!!.mkdirs() + writeText("class SyncService") + } + assetFile = + File(mainDir, "assets/data/levels.json").apply { + parentFile!!.mkdirs() + writeText("{}") + } + File(mainDir, "AndroidManifest.xml").writeText("") + tracker = GenerationTracker(store) + // Every build recompiles the service: the policy then requires a restart deploy. + daemon.compileReply = + DaemonReply.Ok(CompileOutput(File("/fake/classes"), listOf("com/example/SyncService.class"))) + } + + private fun servicePolicy() = + DeployPolicy( + listOf(ComponentInfo(ComponentKind.SERVICE, "com.example.SyncService")), + ) + + private fun executor( + proxyAppPackage: String? = "com.example.quickbuild", + launcher: ProxyAppLauncher? = ProxyAppLauncher { _, _ -> true }, + ) = LiveReloadExecutorImpl( + daemon = daemon, + deploy = deploy, + layout = QuickBuildProjectLayout(projectRoot), + entryActivity = "com.example.MainActivity", + generations = tracker, + workDir = File(projectRoot, ".androidide/quickbuild"), + deployPolicy = servicePolicy(), + proxyAppPackage = proxyAppPackage, + launcherActivity = null, + launcher = launcher, + clock = { 1000L }, + ) + + private fun codeRequest(vararg files: File = arrayOf(sourceFile)) = + BuildRequest( + buildId = 1, + changes = ChangedFiles.Known(files.toSet()), + route = BuildRoute.CodeOnly, + ) + + @Test + fun `a restart deploy without a launcher wired fails telling the user to reopen the app`() = + runTest { + val outcome = executor(launcher = null).execute(codeRequest()) + + val failure = outcome as BuildOutcome.DeployFailure + assertThat(failure.message).contains("could not be relaunched") + assertThat(failure.message).contains("open it manually") + } + + @Test + fun `a restart deploy without a known package fails the same way`() = + runTest { + val launched = mutableListOf() + val outcome = + executor( + proxyAppPackage = null, + launcher = + ProxyAppLauncher { packageName, _ -> + launched += packageName + true + }, + ).execute(codeRequest()) + + assertThat(outcome).isInstanceOf(BuildOutcome.DeployFailure::class.java) + assertThat((outcome as BuildOutcome.DeployFailure).message).contains("could not be relaunched") + // With no package there is nothing to launch - the launcher must not be poked blind. + assertThat(launched).isEmpty() + } + + @Test + fun `a restart deploy whose verdict times out reports the unconfirmed generation`() = + runTest { + deploy.result = DeployResult.TimedOut(15_000) + + val outcome = executor().execute(codeRequest()) + + val failure = outcome as BuildOutcome.DeployFailure + assertThat(failure.message).contains("did not confirm generation 1") + assertThat(failure.message).contains("15000 ms") + } + + @Test + fun `a restart deploy whose payload crashes carries the stack summary`() = + runTest { + deploy.result = DeployResult.Crashed("NPE in SyncService.onCreate") + + val outcome = executor().execute(codeRequest()) + + val failure = outcome as BuildOutcome.DeployFailure + assertThat(failure.message).contains("crashed in the proxy app") + assertThat(failure.message).contains("NPE in SyncService.onCreate") + } + + @Test + fun `a hot-swap deploy that loses its proxy app mid-verdict is a deploy failure`() = + runTest { + // No policy: a plain hot-swap deploy. The app dying mid-deploy is fatal here + // (unlike a restart deploy, where the exit is the expected protocol). + daemon.compileReply = DaemonReply.Ok(CompileOutput(File("/fake/classes"), emptyList())) + deploy.result = DeployResult.Disconnected + val hotSwapExecutor = + LiveReloadExecutorImpl( + daemon = daemon, + deploy = deploy, + layout = QuickBuildProjectLayout(projectRoot), + entryActivity = "com.example.MainActivity", + generations = tracker, + workDir = File(projectRoot, ".androidide/quickbuild"), + clock = { 1000L }, + ) + + val outcome = hotSwapExecutor.execute(codeRequest()) + + assertThat(outcome) + .isEqualTo(BuildOutcome.DeployFailure("Proxy app disconnected during deploy")) + } + + @Test + fun `a restart deploy carrying assets still flags the restart, and carries nothing else`() = + runTest { + executor().execute(codeRequest(sourceFile, assetFile)) + + val metadata = JsonParser.parseString(deploy.calls.single().metadataJson).asJsonObject + assertThat(metadata.get("restart").asString).isEqualTo("true") + // The assets ride in the zip beside the metadata, never inside it: the runtime + // reads exactly these two keys, so any third one is bytes crossing a binder for + // nobody. + assertThat(metadata.keySet()).containsExactly("entryActivity", "restart") + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppBuildRunnerEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppBuildRunnerEdgeTest.kt new file mode 100644 index 0000000000..30df6c6bb6 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppBuildRunnerEdgeTest.kt @@ -0,0 +1,285 @@ +package org.appdevforall.cotg.quickbuild.service.provision + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.data.DaemonReply +import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo +import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout +import org.appdevforall.cotg.quickbuild.data.QuickBuildScratch +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink +import org.appdevforall.cotg.quickbuild.service.FakeDaemon +import org.appdevforall.cotg.quickbuild.service.FakeDeploy +import org.appdevforall.cotg.quickbuild.service.FakePaths +import org.appdevforall.cotg.quickbuild.service.MemoryGenerationStore +import org.appdevforall.cotg.quickbuild.service.deploy.ProxyAppConnections +import org.appdevforall.cotg.quickbuild.service.session.LiveSessionFactory +import org.appdevforall.cotg.quickbuild.service.session.QuickBuildDaemonController +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** + * Failure and supersession corners of [ProxyAppBuildRunner] beyond + * [ProxyAppBuildRunnerTest]: message-less throws, a blocked scratch tree, a daemon that + * rejects (rather than fails) the start, the restart-raced-daemon-start unwind, and + * the artifacts-intact probe's field-by-field contract. + */ +class ProxyAppBuildRunnerEdgeTest { + @TempDir lateinit var projectRoot: File + + private val daemon = FakeDaemon() + private val connections = ProxyAppConnections() + + // Lazy: @TempDir injects projectRoot after construction. + private val scratch by lazy { QuickBuildScratch(FakePaths(projectRoot).projectScratchRoot, minFreeBytes = 0L) } + + private class ScriptedProvisioner : QuickBuildProvisioner { + var provisionOutcome: () -> ProvisionOutcome = { + ProvisionOutcome.Failure(QuickBuildMessage.Literal("unscripted")) + } + var rebuildOutcome: () -> ProxyAppRebuildOutcome = { + ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal("unscripted")) + } + + override suspend fun provision(): ProvisionOutcome = provisionOutcome() + + override suspend fun rebuildProxyApp(): ProxyAppRebuildOutcome = rebuildOutcome() + } + + private val provisioner = ScriptedProvisioner() + + private fun runner(): ProxyAppBuildRunner = + ProxyAppBuildRunner( + provisioner = provisioner, + daemonController = + QuickBuildDaemonController( + daemon = daemon, + scratch = scratch, + paths = FakePaths(projectRoot), + ), + connections = connections, + deploy = FakeDeploy(), + launcher = ProxyAppLauncher { _, _ -> true }, + scratch = scratch, + sessionFactory = + LiveSessionFactory( + daemon = daemon, + deploy = FakeDeploy(), + scratch = scratch, + launcher = ProxyAppLauncher { _, _ -> true }, + metrics = QuickBuildMetricsSink.Noop, + nowMillis = { 1000L }, + executorFactory = null, + watcherFactory = { _, _, _, _ -> error("not reached by these seams") }, + scope = CoroutineScope(StandardTestDispatcher()), + onOrchestratorEvent = {}, + assetsLiveReloadable = true, + ), + generationStoreFactory = { MemoryGenerationStore() }, + metrics = QuickBuildMetricsSink.Noop, + ) + + private fun proxyApp( + classpath: List = emptyList(), + proxyClassesDir: File? = null, + transformedManifest: File? = null, + ) = ProxyAppInfo( + proxyAppPackage = "com.example.quickbuild", + entryActivity = "com.example.MainActivity", + apk = File(projectRoot, "proxy-app.apk"), + classpath = classpath, + proxyClassesDir = proxyClassesDir, + transformedManifest = transformedManifest, + ) + + private fun successOutcome() = + ProvisionOutcome.Success( + proxyApp(), + proxyAppUid = 10001, + layout = QuickBuildProjectLayout(projectRoot), + ) + + @Test + fun `a message-less provisioner throw is reported by exception class name`() = + runTest { + provisioner.provisionOutcome = { throw IllegalStateException() } + + val result = runner().provision(superseded = { false }) + + assertThat(result) + .isEqualTo( + ProxyAppBuildRunner.ProvisionResult.Failed( + QuickBuildMessage.Literal(IllegalStateException::class.java.name), + ), + ) + } + + @Test + fun `a blocked scratch tree fails provisioning with the preparation message`() = + runTest { + provisioner.provisionOutcome = { successOutcome() } + // A stray file where the project's scratch tree must go defeats mkdirs. + val tree = scratch.treeFor(projectRoot) + tree.parentFile!!.mkdirs() + tree.writeText("in the way") + + val result = runner().provision(superseded = { false }) + + assertThat(result).isInstanceOf(ProxyAppBuildRunner.ProvisionResult.Failed::class.java) + assertThat((result as ProxyAppBuildRunner.ProvisionResult.Failed).message) + .isInstanceOf(QuickBuildMessage.ScratchDirUnavailable::class.java) + // Failed before the session/daemon stage: nothing to unwind. + assertThat(daemon.startConfigs).isEmpty() + } + + @Test + fun `a daemon that rejects the configure fails provisioning`() = + runTest { + provisioner.provisionOutcome = { successOutcome() } + daemon.startReply = DaemonReply.BuildFailed(emptyList()) + + val result = runner().provision(superseded = { false }) + + assertThat(result) + .isEqualTo(ProxyAppBuildRunner.ProvisionResult.Failed(QuickBuildMessage.DaemonRejectedConfiguration)) + } + + @Test + fun `a daemon start failure carries the daemon's message`() = + runTest { + provisioner.provisionOutcome = { successOutcome() } + daemon.startReply = DaemonReply.Failed("jdk missing") + + val result = runner().provision(superseded = { false }) + + assertThat(result).isEqualTo(ProxyAppBuildRunner.ProvisionResult.Failed(QuickBuildMessage.Literal("jdk missing"))) + } + + @Test + fun `a restart landing during the daemon start ends the session and reports the special supersession`() = + runTest { + provisioner.provisionOutcome = { successOutcome() } + // False when probed after the Gradle build, true when probed after the daemon + // start - the exact race this result exists for. + var probes = 0 + val superseded = { probes++ > 0 } + + val result = runner().provision(superseded = superseded) + + assertThat(result) + .isEqualTo(ProxyAppBuildRunner.ProvisionResult.SupersededDuringDaemonStart) + // The uid session the runner had begun is ended again... + assertThat(connections.expectedUid).isNull() + // ...and the daemon it started is left for the MANAGER to stop (this + // coroutine is already cancelled in the real flow). + assertThat(daemon.startConfigs).hasSize(1) + } + + @Test + fun `a rebuild outlived by a session restart is Superseded after booking its metric`() = + runTest { + provisioner.rebuildOutcome = { + ProxyAppRebuildOutcome.Success(proxyApp(), QuickBuildProjectLayout(projectRoot)) + } + + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { true }) + + assertThat(result).isEqualTo(ProxyAppBuildRunner.ProxyAppRebuildResult.Superseded) + // The superseded rebuild must NOT restart a daemon for a dead session. + assertThat(daemon.startConfigs).isEmpty() + } + + @Test + fun `a message-less rebuild throw is reported by exception class name`() = + runTest { + provisioner.rebuildOutcome = { throw IllegalStateException() } + + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + + assertThat(result) + .isEqualTo( + ProxyAppBuildRunner.ProxyAppRebuildResult.Failed( + QuickBuildMessage.Literal(IllegalStateException::class.java.name), + ), + ) + } + + @Test + fun `an unconfirmed reinstall passes its message through`() = + runTest { + provisioner.rebuildOutcome = { + ProxyAppRebuildOutcome.InstallNotConfirmed(QuickBuildMessage.Literal("tap install")) + } + + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + + assertThat(result) + .isEqualTo( + ProxyAppBuildRunner.ProxyAppRebuildResult.InstallNotConfirmed( + QuickBuildMessage.Literal("tap install"), + ), + ) + } + + @Test + fun `a daemon that rejects the restart configure reports DaemonRestartFailed with the fallback text`() = + runTest { + provisioner.rebuildOutcome = { + ProxyAppRebuildOutcome.Success(proxyApp(), QuickBuildProjectLayout(projectRoot)) + } + daemon.startReply = DaemonReply.BuildFailed(emptyList()) + + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + + assertThat(result) + .isEqualTo( + ProxyAppBuildRunner.ProxyAppRebuildResult.DaemonRestartFailed( + "daemon rejected configuration", + ), + ) + } + + @Test + fun `artifacts are intact when every reported path still exists`() { + val jar = File(projectRoot, "libs/a.jar").apply { parentFile!!.mkdirs() }.apply { writeText("jar") } + val classes = File(projectRoot, "proxy-classes").apply { mkdirs() } + val manifest = File(projectRoot, "Merged.xml").apply { writeText("") } + + val intact = + runner().proxyAppArtifactsIntact( + proxyApp(classpath = listOf(jar), proxyClassesDir = classes, transformedManifest = manifest), + ) + + assertThat(intact).isTrue() + } + + @Test + fun `a wiped classpath entry means the artifacts are gone`() { + val gone = File(projectRoot, "libs/wiped.jar") + + assertThat(runner().proxyAppArtifactsIntact(proxyApp(classpath = listOf(gone)))).isFalse() + } + + @Test + fun `a wiped proxy classes dir means the artifacts are gone`() { + val gone = File(projectRoot, "proxy-classes-wiped") + + assertThat(runner().proxyAppArtifactsIntact(proxyApp(proxyClassesDir = gone))).isFalse() + } + + @Test + fun `a wiped transformed manifest means the artifacts are gone`() { + val gone = File(projectRoot, "Merged-wiped.xml") + + assertThat(runner().proxyAppArtifactsIntact(proxyApp(transformedManifest = gone))).isFalse() + } + + @Test + fun `absent optional artifacts do not count as wiped`() { + // A pre-v2 setup.json reports neither; their absence is normal, not a wipe. + assertThat(runner().proxyAppArtifactsIntact(proxyApp())).isTrue() + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppBuildRunnerTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppBuildRunnerTest.kt new file mode 100644 index 0000000000..1a00ff80b0 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppBuildRunnerTest.kt @@ -0,0 +1,447 @@ +package org.appdevforall.cotg.quickbuild.service.provision + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.data.DaemonReply +import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo +import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout +import org.appdevforall.cotg.quickbuild.data.QuickBuildScratch +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentInfo +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink +import org.appdevforall.cotg.quickbuild.service.FakeDaemon +import org.appdevforall.cotg.quickbuild.service.FakeDeploy +import org.appdevforall.cotg.quickbuild.service.FakePaths +import org.appdevforall.cotg.quickbuild.service.MemoryGenerationStore +import org.appdevforall.cotg.quickbuild.service.deploy.ProxyAppConnections +import org.appdevforall.cotg.quickbuild.service.session.LiveSessionFactory +import org.appdevforall.cotg.quickbuild.service.session.QuickBuildDaemonController +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** + * Seam tests for the Gradle proxy-app-build runner, directly against + * [ProxyAppBuildRunner] (the manager's tests drive the same paths end-to-end; + * these pin the runner's own contract). + */ +class ProxyAppBuildRunnerTest { + @TempDir lateinit var projectRoot: File + + private val daemon = FakeDaemon() + + /** Records rebuild metric calls; everything else is a no-op. */ + private class RecordingMetrics : QuickBuildMetricsSink { + val rebuilds = mutableListOf() + + /** The relaunch fields of each booked rebuild, parallel to [rebuilds]. */ + val relaunches = mutableListOf>() + + override fun onSessionStarted() = Unit + + override fun onBuildStarted( + buildId: Long, + route: BuildRoute, + changes: ChangedFiles, + ) = Unit + + override fun onBuildFinished( + buildId: Long, + outcome: BuildOutcome, + ) = Unit + + override fun onInvalidation(reason: InvalidationReason) = Unit + + override fun onProxyAppRebuild( + isSuccess: Boolean, + durationMillis: Long, + relaunchOk: Boolean, + toRunningMillis: Long?, + ) { + rebuilds += isSuccess + relaunches += relaunchOk to toRunningMillis + } + } + + /** Scripted provisioner that records call order against the daemon's state. */ + private class FakeProvisioner( + private val daemon: FakeDaemon, + ) : QuickBuildProvisioner { + var provisionCalls = 0 + var rebuildCalls = 0 + + /** The daemon's shutdown count observed when the rebuild's Gradle build ran. */ + var daemonShutdownsAtRebuild = -1 + var provisionOutcome: () -> ProvisionOutcome = { ProvisionOutcome.Failure(QuickBuildMessage.Literal("unscripted")) } + var rebuildOutcome: () -> ProxyAppRebuildOutcome = { ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal("unscripted")) } + + override suspend fun provision(): ProvisionOutcome { + provisionCalls++ + return provisionOutcome() + } + + override suspend fun rebuildProxyApp(): ProxyAppRebuildOutcome { + rebuildCalls++ + daemonShutdownsAtRebuild = daemon.shutdownCount + return rebuildOutcome() + } + } + + private val metrics = RecordingMetrics() + private val provisioner = FakeProvisioner(daemon) + private val connections = ProxyAppConnections() + private val deploy = FakeDeploy() + + /** Every rebuild relaunch, as (package, launcherActivity) - the deployRestart shape. */ + private val launches = mutableListOf>() + + /** What the launcher answers; false stands in for a refused start. */ + private var launchResult = true + + private fun runner(minFreeBytes: Long = 0L): ProxyAppBuildRunner { + val scratch = QuickBuildScratch(FakePaths(projectRoot).projectScratchRoot, minFreeBytes) + val daemonController = + QuickBuildDaemonController( + daemon = daemon, + scratch = scratch, + paths = FakePaths(projectRoot), + ) + return ProxyAppBuildRunner( + provisioner = provisioner, + daemonController = daemonController, + connections = connections, + deploy = deploy, + launcher = + ProxyAppLauncher { packageName, activityClass -> + launches += packageName to activityClass + launchResult + }, + scratch = scratch, + sessionFactory = + LiveSessionFactory( + daemon = daemon, + deploy = FakeDeploy(), + scratch = scratch, + launcher = ProxyAppLauncher { _, _ -> true }, + metrics = metrics, + nowMillis = { 1000L }, + executorFactory = null, + watcherFactory = { _, _, _, _ -> error("not used by these seams") }, + scope = CoroutineScope(StandardTestDispatcher()), + onOrchestratorEvent = {}, + assetsLiveReloadable = true, + ), + generationStoreFactory = { MemoryGenerationStore() }, + metrics = metrics, + ) + } + + private fun proxyApp( + root: File = projectRoot, + entryActivity: String? = "com.example.MainActivity", + components: List = emptyList(), + ) = ProxyAppInfo( + proxyAppPackage = "com.example.quickbuild", + entryActivity = entryActivity, + apk = File(root, "proxy-app.apk"), + classpath = emptyList(), + proxyClassesDir = null, + transformedManifest = null, + components = components, + ) + + @Test + fun `a deferred rebuild - slot busy while parked - books no rebuild metric`() = + runTest { + provisioner.rebuildOutcome = { ProxyAppRebuildOutcome.BuildSlotBusy } + val result = runner().rebuildProxyApp(parkedRetry = true, superseded = { false }) + assertThat(result).isEqualTo(ProxyAppBuildRunner.ProxyAppRebuildResult.BuildSlotBusy) + assertThat(metrics.rebuilds).isEmpty() + } + + @Test + fun `a first rebuild losing the slot books a failed rebuild metric`() = + runTest { + provisioner.rebuildOutcome = { ProxyAppRebuildOutcome.BuildSlotBusy } + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + assertThat(result).isEqualTo(ProxyAppBuildRunner.ProxyAppRebuildResult.BuildSlotBusy) + assertThat(metrics.rebuilds).containsExactly(false) + } + + @Test + fun `the daemon is down during the Gradle build and restarts against the NEW setup's config`() = + runTest { + daemon.isRunning = true + val newRoot = File(projectRoot, "moved-project").apply { mkdirs() } + provisioner.rebuildOutcome = { + ProxyAppRebuildOutcome.Success(proxyApp(newRoot), QuickBuildProjectLayout(newRoot)) + } + + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + + assertThat(result) + .isInstanceOf(ProxyAppBuildRunner.ProxyAppRebuildResult.Succeeded::class.java) + // Shut down BEFORE the Gradle build ran (the two must not coexist in memory). + assertThat(provisioner.daemonShutdownsAtRebuild).isEqualTo(1) + // Restarted against the NEW setup's config, not the old baseline's. + assertThat(daemon.startConfigs.single().projectRoot).isEqualTo(newRoot) + assertThat(metrics.rebuilds).containsExactly(true) + } + + @Test + fun `a successful rebuild relaunches the reinstalled app at the proxied launcher activity`() = + runTest { + provisioner.rebuildOutcome = { + ProxyAppRebuildOutcome.Success( + proxyApp( + components = + listOf( + // A non-launcher activity declared first, so the assertion pins + // "the launcher one", not "the first one". + ComponentInfo(ComponentKind.ACTIVITY, "com.example.Other", proxyClass = "com.example.QbOther"), + ComponentInfo( + ComponentKind.ACTIVITY, + "com.example.MainActivity", + proxyClass = "com.example.QbMain", + launcher = true, + ), + ), + ), + QuickBuildProjectLayout(projectRoot), + ) + } + deploy.reconnectGeneration = { 7L } + + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + + assertThat(result) + .isInstanceOf(ProxyAppBuildRunner.ProxyAppRebuildResult.Succeeded::class.java) + // Same (package, launcherActivity) shape the restart deploy launches with. + assertThat(launches).containsExactly("com.example.quickbuild" to "com.example.QbMain") + assertThat(metrics.rebuilds).containsExactly(true) + val (relaunchOk, toRunningMillis) = metrics.relaunches.single() + assertThat(relaunchOk).isTrue() + assertThat(toRunningMillis).isNotNull() + } + + @Test + fun `an alias-launched app relaunches with a null activity so the default launch intent resolves it`() = + runTest { + // No proxied activity carries MAIN/LAUNCHER - the case. + provisioner.rebuildOutcome = { + ProxyAppRebuildOutcome.Success(proxyApp(), QuickBuildProjectLayout(projectRoot)) + } + deploy.reconnectGeneration = { 7L } + + runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + + assertThat(launches).containsExactly("com.example.quickbuild" to null) + } + + @Test + fun `a failed rebuild never relaunches and books relaunchOk false`() = + runTest { + provisioner.rebuildOutcome = { + ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal("bad build.gradle")) + } + + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + + assertThat(result) + .isEqualTo(ProxyAppBuildRunner.ProxyAppRebuildResult.Failed(QuickBuildMessage.Literal("bad build.gradle"))) + assertThat(launches).isEmpty() + assertThat(metrics.relaunches).containsExactly(false to null) + } + + @Test + fun `a daemon restart failure never relaunches - a live app next to the failure would lie`() = + runTest { + provisioner.rebuildOutcome = { + ProxyAppRebuildOutcome.Success(proxyApp(), QuickBuildProjectLayout(projectRoot)) + } + daemon.startReply = DaemonReply.Failed("no memory") + + runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + + assertThat(launches).isEmpty() + // The Gradle build itself succeeded, so isSuccess stays true as before... + assertThat(metrics.rebuilds).containsExactly(true) + // ...but the relaunch fields must not read like a relaunched app. + assertThat(metrics.relaunches).containsExactly(false to null) + } + + @Test + fun `a refused relaunch start leaves the rebuild Succeeded but books relaunchOk false`() = + runTest { + provisioner.rebuildOutcome = { + ProxyAppRebuildOutcome.Success(proxyApp(), QuickBuildProjectLayout(projectRoot)) + } + launchResult = false + + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + + // The relaunch is best-effort: the baseline and daemon are fine, so the + // rebuild result must not fail on it. + assertThat(result) + .isInstanceOf(ProxyAppBuildRunner.ProxyAppRebuildResult.Succeeded::class.java) + // A refused start is not a swallowed start; no retry. + assertThat(launches).hasSize(1) + assertThat(metrics.relaunches).containsExactly(false to null) + } + + @Test + fun `a swallowed first start gets exactly one more launch, and a reconnect then counts`() = + runTest { + provisioner.rebuildOutcome = { + ProxyAppRebuildOutcome.Success(proxyApp(), QuickBuildProjectLayout(projectRoot)) + } + val reconnects = ArrayDeque(listOf(null, 7L)) + deploy.reconnectGeneration = { reconnects.removeFirst() } + + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + + assertThat(result) + .isInstanceOf(ProxyAppBuildRunner.ProxyAppRebuildResult.Succeeded::class.java) + assertThat(launches).hasSize(2) + val (relaunchOk, toRunningMillis) = metrics.relaunches.single() + assertThat(relaunchOk).isTrue() + assertThat(toRunningMillis).isNotNull() + } + + @Test + fun `a relaunch that never reconnects books relaunchOk false with no toRunningMillis`() = + runTest { + provisioner.rebuildOutcome = { + ProxyAppRebuildOutcome.Success(proxyApp(), QuickBuildProjectLayout(projectRoot)) + } + deploy.reconnectGeneration = { null } + + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + + // Two starts were issued (the swallowed-start retry), then it gave up. + assertThat(launches).hasSize(2) + // Still a success: the new baseline is installed and the daemon is up. + assertThat(result) + .isInstanceOf(ProxyAppBuildRunner.ProxyAppRebuildResult.Succeeded::class.java) + assertThat(metrics.rebuilds).containsExactly(true) + assertThat(metrics.relaunches).containsExactly(false to null) + } + + @Test + fun `an unconfirmed reinstall books relaunchOk false and never launches`() = + runTest { + provisioner.rebuildOutcome = { + ProxyAppRebuildOutcome.InstallNotConfirmed(QuickBuildMessage.Literal("tap install")) + } + + runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + + assertThat(launches).isEmpty() + assertThat(metrics.relaunches).containsExactly(false to null) + } + + @Test + fun `a superseded rebuild books its metric with relaunchOk false and never launches`() = + runTest { + provisioner.rebuildOutcome = { + ProxyAppRebuildOutcome.Success(proxyApp(), QuickBuildProjectLayout(projectRoot)) + } + + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { true }) + + assertThat(result).isEqualTo(ProxyAppBuildRunner.ProxyAppRebuildResult.Superseded) + assertThat(launches).isEmpty() + assertThat(metrics.rebuilds).containsExactly(true) + assertThat(metrics.relaunches).containsExactly(false to null) + } + + @Test + fun `a daemon that refuses the restart yields DaemonRestartFailed`() = + runTest { + provisioner.rebuildOutcome = { + ProxyAppRebuildOutcome.Success(proxyApp(), QuickBuildProjectLayout(projectRoot)) + } + daemon.startReply = DaemonReply.Failed("no memory") + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + assertThat(result) + .isEqualTo(ProxyAppBuildRunner.ProxyAppRebuildResult.DaemonRestartFailed("no memory")) + } + + @Test + fun `a rebuild provisioner that throws becomes Failed, not a propagated exception`() = + runTest { + provisioner.rebuildOutcome = { throw IllegalStateException("gradle exploded") } + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + assertThat(result) + .isEqualTo(ProxyAppBuildRunner.ProxyAppRebuildResult.Failed(QuickBuildMessage.Literal("gradle exploded"))) + // A real attempt that died still books a failed rebuild. + assertThat(metrics.rebuilds).containsExactly(false) + } + + @Test + fun `a disk-space shortfall short-circuits before the provisioner is called at all`() = + runTest { + val result = runner(minFreeBytes = Long.MAX_VALUE).provision(superseded = { false }) + assertThat(result) + .isInstanceOf(ProxyAppBuildRunner.ProvisionResult.DiskSpaceShort::class.java) + assertThat(provisioner.provisionCalls).isEqualTo(0) + } + + @Test + fun `a provisioner that throws becomes Failed, not a propagated exception`() = + runTest { + provisioner.provisionOutcome = { throw IllegalStateException("provision exploded") } + val result = runner().provision(superseded = { false }) + assertThat(result) + .isEqualTo(ProxyAppBuildRunner.ProvisionResult.Failed(QuickBuildMessage.Literal("provision exploded"))) + } + + @Test + fun `a session assembly throw after the daemon started unwinds the session and daemon and becomes Failed`() = + runTest { + provisioner.provisionOutcome = { + ProvisionOutcome.Success( + // Null entryActivity makes sessionFactory.create throw its checkNotNull - + // the assembly-stage throw the runner's error boundary must catch instead + // of letting it crash the session scope with a uid session registered. + proxyApp(entryActivity = null), + proxyAppUid = 10001, + layout = QuickBuildProjectLayout(projectRoot), + ) + } + + val result = runner().provision(superseded = { false }) + + assertThat(result).isInstanceOf(ProxyAppBuildRunner.ProvisionResult.Failed::class.java) + assertThat((result as ProxyAppBuildRunner.ProvisionResult.Failed).message) + .isEqualTo(QuickBuildMessage.Literal("Quick Build session started without an entry activity")) + // The uid session registered before the throw was ended... + assertThat(connections.expectedPackage).isNull() + assertThat(connections.expectedUid).isNull() + // ...and the daemon started before it was shut down, intentionally (no respawn). + assertThat(daemon.shutdownCount).isEqualTo(1) + assertThat(daemon.isRunning).isFalse() + } + + @Test + fun `a provision outlived by a session restart is Superseded and starts no daemon`() = + runTest { + provisioner.provisionOutcome = { + ProvisionOutcome.Success( + proxyApp(), + proxyAppUid = 10001, + layout = QuickBuildProjectLayout(projectRoot), + ) + } + val result = runner().provision(superseded = { true }) + assertThat(result).isEqualTo(ProxyAppBuildRunner.ProvisionResult.Superseded) + assertThat(daemon.startConfigs).isEmpty() + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImplEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImplEdgeTest.kt new file mode 100644 index 0000000000..31538daf4f --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImplEdgeTest.kt @@ -0,0 +1,259 @@ +package org.appdevforall.cotg.quickbuild.service.session + +import com.google.common.truth.Truth.assertThat +import com.google.gson.JsonParser +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.data.CompileOutput +import org.appdevforall.cotg.quickbuild.data.DaemonReply +import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.reload.BuildRequest +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentInfo +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind +import org.appdevforall.cotg.quickbuild.domain.reload.DeployPolicy +import org.appdevforall.cotg.quickbuild.domain.reload.GenerationTracker +import org.appdevforall.cotg.quickbuild.service.FakeDaemon +import org.appdevforall.cotg.quickbuild.service.FakeDeploy +import org.appdevforall.cotg.quickbuild.service.MemoryGenerationStore +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** + * Edge paths of [LiveReloadExecutorImpl] beyond [LiveReloadExecutorImplTest]'s route + * coverage: the outer pipeline-failure guard, relink failures on the resource routes, + * source-extension filtering, the transformed-manifest preference, and the deploy policy's + * effect on the metadata a deploy carries. + */ +class LiveReloadExecutorImplEdgeTest { + @TempDir lateinit var projectRoot: File + + private val daemon = FakeDaemon() + private val deploy = FakeDeploy() + private val store = MemoryGenerationStore() + + private lateinit var tracker: GenerationTracker + private lateinit var sourceFile: File + private lateinit var javaFile: File + private lateinit var resFile: File + + @BeforeEach + fun setUp() { + val mainDir = File(projectRoot, "app/src/main") + sourceFile = + File(mainDir, "java/com/example/Foo.kt").apply { + parentFile!!.mkdirs() + writeText("class Foo") + } + javaFile = File(mainDir, "java/com/example/Legacy.java").apply { writeText("class Legacy {}") } + resFile = + File(mainDir, "res/values/strings.xml").apply { + parentFile!!.mkdirs() + writeText("") + } + File(mainDir, "AndroidManifest.xml").writeText("") + tracker = GenerationTracker(store) + } + + private fun executor( + clock: () -> Long = { 1000L }, + proxyAppManifest: File? = null, + deployPolicy: DeployPolicy? = null, + ) = LiveReloadExecutorImpl( + daemon = daemon, + deploy = deploy, + layout = QuickBuildProjectLayout(projectRoot), + entryActivity = "com.example.MainActivity", + generations = tracker, + workDir = File(projectRoot, ".androidide/quickbuild"), + proxyAppManifest = proxyAppManifest, + deployPolicy = deployPolicy, + clock = clock, + ) + + private fun request( + route: BuildRoute, + changes: ChangedFiles = ChangedFiles.Known.EMPTY, + ) = BuildRequest(buildId = 1, changes = changes, route = route) + + @Test + fun `a pipeline throw maps to InfrastructureFailure with the exception's message`() = + runTest { + val outcome = + executor(clock = { throw IllegalStateException("clock exploded") }) + .execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isEqualTo(BuildOutcome.InfrastructureFailure("clock exploded")) + assertThat(deploy.calls).isEmpty() + } + + @Test + fun `a message-less pipeline throw falls back to the exception class name`() = + runTest { + val outcome = + executor(clock = { throw IllegalStateException() }) + .execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome) + .isEqualTo(BuildOutcome.InfrastructureFailure(IllegalStateException::class.java.name)) + } + + @Test + fun `a resources-only relink infrastructure failure surfaces without a deploy`() = + runTest { + daemon.relinkReply = DaemonReply.Failed("aapt2 missing", daemonDied = false) + + val outcome = + executor().execute(request(BuildRoute.ResourcesOnly, ChangedFiles.Known(setOf(resFile)))) + + assertThat(outcome).isEqualTo(BuildOutcome.InfrastructureFailure("aapt2 missing")) + assertThat(deploy.calls).isEmpty() + assertThat(tracker.current).isEqualTo(0) + } + + @Test + fun `a mixed route whose relink fails surfaces the failure after a green compile`() = + runTest { + daemon.relinkReply = DaemonReply.Failed("relink socket closed", daemonDied = true) + + val outcome = + executor().execute( + request(BuildRoute.CodeAndResources, ChangedFiles.Known(setOf(sourceFile, resFile))), + ) + + // The compile ran (its half is green) but nothing may deploy on a half-built payload. + assertThat(daemon.compileCalls).hasSize(1) + val failure = outcome as BuildOutcome.InfrastructureFailure + assertThat(failure.message).isEqualTo("relink socket closed") + assertThat(failure.daemonDied).isTrue() + assertThat(deploy.calls).isEmpty() + } + + @Test + fun `an assets-only route with nothing packageable succeeds without a deploy`() = + runTest { + // The only "change" resolves under no asset root, so the packager has nothing + // to ship - and the executor must not fabricate a payload. + val ghost = File(projectRoot, "app/src/main/assets-old/ghost.json") + + val outcome = + executor().execute( + request(BuildRoute.AssetsOnly, ChangedFiles.Known(emptySet(), removed = setOf(ghost))), + ) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(0, 0)) + assertThat(deploy.calls).isEmpty() + assertThat(daemon.compileCalls).isEmpty() + } + + @Test + fun `changed assets ride along on a resources route`() = + runTest { + val asset = + File(projectRoot, "app/src/main/assets/data/levels.json").apply { + parentFile!!.mkdirs() + writeText("{}") + } + + executor().execute( + request(BuildRoute.ResourcesOnly, ChangedFiles.Known(setOf(resFile, asset))), + ) + + assertThat(deploy.calls.single().assetsZip).isNotNull() + assertThat(deploy.calls.single().arscFile).isNotNull() + } + + @Test + fun `java sources ride the changed set and non-sources are filtered out`() = + runTest { + val stray = File(projectRoot, "app/src/main/java/com/example/notes.txt").apply { writeText("x") } + + executor().execute( + request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(javaFile, stray))), + ) + + assertThat(daemon.compileCalls.single().second).containsExactly(javaFile) + } + + @Test + fun `removed non-sources are filtered from the compiler's removed set`() = + runTest { + val removedJava = File(projectRoot, "app/src/main/java/com/example/Gone.java") + val removedStray = File(projectRoot, "app/src/main/java/com/example/gone.txt") + + executor().execute( + request( + BuildRoute.CodeOnly, + ChangedFiles.Known(setOf(sourceFile), removed = setOf(removedJava, removedStray)), + ), + ) + + assertThat(daemon.compileRemovedFiles.single()).containsExactly(removedJava) + } + + @Test + fun `relinks link against the transformed manifest when the proxy app build produced one`() = + runTest { + val transformed = File(projectRoot, "transformed/AndroidManifest.xml") + + executor(proxyAppManifest = transformed) + .execute(request(BuildRoute.ResourcesOnly, ChangedFiles.Known(setOf(resFile)))) + + assertThat(daemon.relinkCalls.single().manifest).isEqualTo(transformed) + } + + @Test + fun `a helper-only edit sends restart metadata when the app declares a service`() = + runTest { + // The end-to-end half of the restart rule: nothing about this edit names the + // service, and the deploy must still carry `restart` because the payload it ships + // redefines the service class along with everything else. + daemon.compileReply = + DaemonReply.Ok( + CompileOutput(File("/fake/classes"), listOf("com/example/Helper.class")), + ) + + executor( + deployPolicy = + DeployPolicy( + listOf(ComponentInfo(ComponentKind.SERVICE, "com.example.SyncService")), + ), + ).execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + val metadata = JsonParser.parseString(deploy.calls.single().metadataJson).asJsonObject + assertThat(metadata.get("restart").asString).isEqualTo("true") + } + + @Test + fun `the same edit hot-swaps when the app declares no held component`() = + runTest { + // The negative control for the test above: same edit, same pipeline, a component + // list with nothing a loader swap cannot update. + daemon.compileReply = + DaemonReply.Ok( + CompileOutput(File("/fake/classes"), listOf("com/example/Helper.class")), + ) + + val outcome = + executor( + deployPolicy = + DeployPolicy( + listOf( + ComponentInfo( + ComponentKind.ACTIVITY, + "com.example.MainActivity", + proxyClass = "com.example.quickbuild.proxies.Proxy0Activity", + launcher = true, + ), + ), + ), + ).execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0)) + val metadata = JsonParser.parseString(deploy.calls.single().metadataJson).asJsonObject + assertThat(metadata.has("restart")).isFalse() + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImplTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImplTest.kt new file mode 100644 index 0000000000..9c0c09ca9e --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImplTest.kt @@ -0,0 +1,1670 @@ +package org.appdevforall.cotg.quickbuild.service.session + +import com.google.common.truth.Truth.assertThat +import com.google.gson.JsonParser +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.data.CompileOutput +import org.appdevforall.cotg.quickbuild.data.DaemonReply +import org.appdevforall.cotg.quickbuild.data.DexOutput +import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout +import org.appdevforall.cotg.quickbuild.data.RelinkOutput +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.reload.BuildRequest +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentInfo +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind +import org.appdevforall.cotg.quickbuild.domain.reload.DeployPolicy +import org.appdevforall.cotg.quickbuild.domain.reload.GenerationTracker +import org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink +import org.appdevforall.cotg.quickbuild.protocol.CompileStats +import org.appdevforall.cotg.quickbuild.protocol.DexStats +import org.appdevforall.cotg.quickbuild.service.FakeDaemon +import org.appdevforall.cotg.quickbuild.service.FakeDeploy +import org.appdevforall.cotg.quickbuild.service.MemoryGenerationStore +import org.appdevforall.cotg.quickbuild.service.deploy.DeployResult +import org.appdevforall.cotg.quickbuild.service.deploy.RetainedPayloadStore +import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppLauncher +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.util.zip.ZipFile + +class LiveReloadExecutorImplTest { + @TempDir lateinit var projectRoot: File + + private val daemon = FakeDaemon() + private val deploy = FakeDeploy() + private val store = MemoryGenerationStore() + private val launchCalls = mutableListOf>() + + private lateinit var tracker: GenerationTracker + private lateinit var sourceFile: File + private lateinit var resFile: File + private lateinit var assetFile: File + private lateinit var executor: LiveReloadExecutorImpl + + @BeforeEach + fun setUp() { + val mainDir = File(projectRoot, "app/src/main") + sourceFile = + File(mainDir, "java/com/example/Foo.kt").apply { + parentFile!!.mkdirs() + writeText("class Foo") + } + resFile = + File(mainDir, "res/values/strings.xml").apply { + parentFile!!.mkdirs() + writeText("") + } + assetFile = + File(mainDir, "assets/data/levels.json").apply { + parentFile!!.mkdirs() + writeText("{}") + } + File(mainDir, "AndroidManifest.xml").writeText("") + + tracker = GenerationTracker(store) + executor = + LiveReloadExecutorImpl( + daemon = daemon, + deploy = deploy, + layout = QuickBuildProjectLayout(projectRoot), + entryActivity = "com.example.MainActivity", + generations = tracker, + workDir = File(projectRoot, ".androidide/quickbuild"), + clock = { 1000L }, + ) + } + + /** + * Builds a tap's request by default. These tests are about the deploy pipeline rather than + * who asked for it, and only a tap may open a closed app - a save's refusal to launch is + * pinned in PayloadDeployerTest instead. + */ + private fun request( + route: BuildRoute, + changes: ChangedFiles = ChangedFiles.Known.EMPTY, + forced: Boolean = false, + userInitiated: Boolean = true, + ) = BuildRequest( + buildId = 1, + changes = changes, + route = route, + forced = forced, + userInitiated = userInitiated, + ) + + private fun metadataOf(call: FakeDeploy.Call) = JsonParser.parseString(call.metadataJson).asJsonObject + + @Test + fun `a confirmed deploy is retained under the work dir where forWorkDir reads it`() = + runTest { + // S8 agreement pin, writer side: the executor derives its retention store + // internally from workDir; the session manager's reconnect re-send reads through + // RetainedPayloadStore.forWorkDir over the same dir. If the two derivations + // diverge, retention is silently never found and every reconnect pays the forced + // rebuild S8 removed. + daemon.dexReply = + DaemonReply.Ok( + DexOutput( + File(projectRoot, "built/classes.dex").apply { + parentFile!!.mkdirs() + writeText("dex-bytes") + }, + ), + ) + + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + val retained = + RetainedPayloadStore + .forWorkDir(File(projectRoot, ".androidide/quickbuild")) + .load() + assertThat(retained).isNotNull() + assertThat(retained!!.generation).isEqualTo(1) + assertThat(retained.dexFile!!.readText()).isEqualTo("dex-bytes") + } + + @Test + fun `code-only route compiles, dexes and deploys the dex`() = + runTest { + val outcome = + executor.execute( + request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile))), + ) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0)) + assertThat(daemon.compileCalls).hasSize(1) + assertThat(daemon.compileCalls[0].second).containsExactly(sourceFile) + assertThat(daemon.dexCalls).hasSize(1) + assertThat(daemon.relinkCalls).isEmpty() + + val call = deploy.calls.single() + assertThat(call.generation).isEqualTo(1) + assertThat(call.dexFile).isNotNull() + assertThat(call.arscFile).isNull() + assertThat(call.assetsZip).isNull() + val metadata = metadataOf(call) + assertThat(metadata.get("entryActivity").asString).isEqualTo("com.example.MainActivity") + } + + @Test + fun `warm-compile route compiles everything and dexes but deploys NOTHING at an unmoved generation`() = + runTest { + val outcome = executor.execute(request(BuildRoute.WarmCompile, ChangedFiles.Unknown)) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(0, 0)) + // The whole source set goes through the compiler (IC-cache priming)... + assertThat(daemon.compileCalls).hasSize(1) + assertThat(daemon.compileCalls[0].second).containsExactly(sourceFile) + // ...d8 warms too... + assertThat(daemon.dexCalls).hasSize(1) + // ...but nothing reaches the device: no deploy, no relink, generation unmoved. + assertThat(deploy.calls).isEmpty() + assertThat(daemon.relinkCalls).isEmpty() + assertThat(tracker.current).isEqualTo(0) + } + + // Review gap (2026-07-26 #69): the warm compile is invisible by contract - the proxy app + // already runs exactly the sources it compiles - so its overlay must not flash + // "build ok" for a build the user never triggered. + @Test + fun `a warm-compile success stays silent on the proxy-app status channel`() = + runTest { + val outcome = executor.execute(request(BuildRoute.WarmCompile, ChangedFiles.Unknown)) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(0, 0)) + assertThat(deploy.statusCalls).isEmpty() + } + + @Test + fun `a warm-compile compile error stays silent on the proxy-app status channel but keeps the real outcome`() = + runTest { + val diagnostics = + listOf( + BuildDiagnostic( + severity = BuildDiagnostic.Severity.ERROR, + message = "unresolved reference", + file = sourceFile.path, + line = 1, + ), + ) + daemon.compileReply = DaemonReply.BuildFailed(diagnostics) + + val outcome = executor.execute(request(BuildRoute.WarmCompile, ChangedFiles.Unknown)) + + // The orchestrator still needs the honest outcome (it routes recovery), + // but the proxy-app overlay must not flash "build failed" for sources the + // app is running fine - the proxy app build compiled them green moments ago. + assertThat(outcome).isEqualTo(BuildOutcome.CompileError(diagnostics)) + assertThat(deploy.statusCalls).isEmpty() + assertThat(deploy.calls).isEmpty() + } + + @Test + fun `a removed source is threaded into the compiler's removedFiles, not its changed set`() = + runTest { + val removedSource = File(projectRoot, "app/src/main/java/com/example/Gone.kt") + + val outcome = + executor.execute( + request( + BuildRoute.CodeOnly, + ChangedFiles.Known(files = setOf(sourceFile), removed = setOf(removedSource)), + ), + ) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0)) + assertThat(daemon.compileCalls).hasSize(1) + // The live edit is a changed source; the deleted one is a removed source. + assertThat(daemon.compileCalls[0].second).containsExactly(sourceFile) + assertThat(daemon.compileRemovedFiles.single()).containsExactly(removedSource) + } + + @Test + fun `a pure deletion compiles with an empty changed set and the removed source`() = + runTest { + val removedSource = File(projectRoot, "app/src/main/java/com/example/Gone.kt") + + executor.execute( + request(BuildRoute.CodeOnly, ChangedFiles.Known(files = emptySet(), removed = setOf(removedSource))), + ) + + assertThat(daemon.compileCalls.single().second).isEmpty() + assertThat(daemon.compileRemovedFiles.single()).containsExactly(removedSource) + } + + @Test + fun `resources-only route relinks and deploys the arsc without touching the compiler`() = + runTest { + val outcome = + executor.execute( + request(BuildRoute.ResourcesOnly, ChangedFiles.Known(setOf(resFile))), + ) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0)) + assertThat(daemon.compileCalls).isEmpty() + assertThat(daemon.dexCalls).isEmpty() + assertThat(daemon.relinkCalls).hasSize(1) + + val call = deploy.calls.single() + assertThat(call.dexFile).isNull() + assertThat(call.arscFile).isNotNull() + } + + @Test + fun `relink passes the layout's stable-ids file to the daemon`() = + runTest { + val stableIds = + File(projectRoot, "app/build/intermediates/stable_resource_ids_file/debug/processDebugResources/stableIds.txt") + .apply { + parentFile!!.mkdirs() + writeText("demo:string/app_name = 0x7f010000") + } + val executorWithStableIds = + LiveReloadExecutorImpl( + daemon = daemon, + deploy = deploy, + layout = QuickBuildProjectLayout(projectRoot, stableIdsFile = stableIds), + entryActivity = "com.example.MainActivity", + generations = tracker, + workDir = File(projectRoot, ".androidide/quickbuild"), + clock = { 1000L }, + ) + + executorWithStableIds.execute(request(BuildRoute.ResourcesOnly, ChangedFiles.Known(setOf(resFile)))) + + assertThat(daemon.relinkCalls).hasSize(1) + assertThat(daemon.relinkCalls.single().stableIdsFile).isEqualTo(stableIds) + } + + @Test + fun `relink passes the layout's library-resource units to the daemon`() = + runTest { + // a relink of the project's own res/ alone can't resolve a + // resource a dependency AAR provides, so the layout's reported merged_res / + // dependency-resource units must reach the daemon on every relink. + val libraryResource = + File(projectRoot, "app/build/intermediates/merged_res/debug/values_values.arsc.flat") + .apply { + parentFile!!.mkdirs() + writeText("") + } + val executorWithLibraryResources = + LiveReloadExecutorImpl( + daemon = daemon, + deploy = deploy, + layout = QuickBuildProjectLayout(projectRoot, libraryResourceFlats = listOf(libraryResource)), + entryActivity = "com.example.MainActivity", + generations = tracker, + workDir = File(projectRoot, ".androidide/quickbuild"), + clock = { 1000L }, + ) + + executorWithLibraryResources.execute(request(BuildRoute.ResourcesOnly, ChangedFiles.Known(setOf(resFile)))) + + assertThat(daemon.relinkCalls).hasSize(1) + assertThat(daemon.relinkCalls.single().libraryResources).containsExactly(libraryResource) + } + + @Test + fun `mixed route compiles AND relinks - never stale resources beside new code`() = + runTest { + val outcome = + executor.execute( + request( + BuildRoute.CodeAndResources, + ChangedFiles.Known(setOf(sourceFile, resFile)), + ), + ) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0)) + assertThat(daemon.compileCalls).hasSize(1) + assertThat(daemon.relinkCalls).hasSize(1) + + val call = deploy.calls.single() + assertThat(call.dexFile).isNotNull() + assertThat(call.arscFile).isNotNull() + } + + @Test + fun `assets-only route deploys a zip of the changed assets and skips the daemon`() = + runTest { + val outcome = + executor.execute( + request(BuildRoute.AssetsOnly, ChangedFiles.Known(setOf(assetFile))), + ) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0)) + assertThat(daemon.compileCalls).isEmpty() + assertThat(daemon.relinkCalls).isEmpty() + + val call = deploy.calls.single() + assertThat(call.dexFile).isNull() + assertThat(call.arscFile).isNull() + assertThat(call.assetsZip).isNotNull() + + ZipFile(call.assetsZip!!).use { zip -> + assertThat(zip.entries().toList().map { it.name }).containsExactly("data/levels.json") + } + } + + @Test + fun `changed assets ride along on a code route`() = + runTest { + executor.execute( + request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile, assetFile))), + ) + + val call = deploy.calls.single() + assertThat(call.dexFile).isNotNull() + assertThat(call.assetsZip).isNotNull() + // The zip is the only channel the assets travel on, so its contents are what + // proves they rode along - the metadata carries no asset list. + ZipFile(call.assetsZip!!).use { zip -> + assertThat(zip.entries().toList().map { it.name }).containsExactly("data/levels.json") + } + } + + @Test + fun `compile error maps to CompileError, burns no generation and never deploys`() = + runTest { + val diagnostics = + listOf( + BuildDiagnostic( + severity = BuildDiagnostic.Severity.ERROR, + message = "unresolved reference", + file = sourceFile.path, + line = 1, + ), + ) + daemon.compileReply = DaemonReply.BuildFailed(diagnostics) + + val outcome = + executor.execute( + request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile))), + ) + + assertThat(outcome).isEqualTo(BuildOutcome.CompileError(diagnostics)) + assertThat(deploy.calls).isEmpty() + assertThat(tracker.current).isEqualTo(0) + } + + @Test + fun `compile error notifies the proxy app without the failing location`() = + runTest { + daemon.compileReply = + DaemonReply.BuildFailed( + listOf( + BuildDiagnostic( + severity = BuildDiagnostic.Severity.ERROR, + message = "unresolved reference: foo\nsecond line", + file = sourceFile.path, + line = 3, + column = 7, + ), + ), + ) + + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + val status = JsonParser.parseString(deploy.statusCalls.single()).asJsonObject + assertThat(status.get("kind").asString).isEqualTo("build_failed") + assertThat(status.get("message").asString).isEqualTo("unresolved reference: foo") + // The overlay only warns that the app is stale; finding the error is CoGo's job, + // so no host-side path reaches the device. + assertThat(status.has("file")).isFalse() + assertThat(status.has("line")).isFalse() + assertThat(status.has("column")).isFalse() + } + + @Test + fun `success notifies build_ok so a previously shown failure clears`() = + runTest { + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + val status = JsonParser.parseString(deploy.statusCalls.single()).asJsonObject + assertThat(status.get("kind").asString).isEqualTo("build_ok") + } + + @Test + fun `deploy and infrastructure failures send no build status`() = + runTest { + deploy.result = DeployResult.TimedOut(15_000) + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + daemon.compileReply = DaemonReply.Failed("daemon gone", daemonDied = true) + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(deploy.statusCalls).isEmpty() + } + + @Test + fun `daemon death during compile maps to InfrastructureFailure with daemonDied`() = + runTest { + daemon.compileReply = DaemonReply.Failed("daemon gone", daemonDied = true) + + val outcome = + executor.execute( + request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile))), + ) + + assertThat(outcome).isEqualTo(BuildOutcome.InfrastructureFailure("daemon gone", true)) + assertThat(deploy.calls).isEmpty() + } + + @Test + fun `deploy timeout maps to DeployFailure`() = + runTest { + deploy.result = DeployResult.TimedOut(15_000) + + val outcome = + executor.execute( + request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile))), + ) + + assertThat(outcome).isInstanceOf(BuildOutcome.DeployFailure::class.java) + } + + @Test + fun `proxy-app crash during deploy maps to DeployFailure carrying the summary`() = + runTest { + deploy.result = DeployResult.Crashed("NullPointerException at Foo.kt:1") + + val outcome = + executor.execute( + request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile))), + ) + + assertThat(outcome).isInstanceOf(BuildOutcome.DeployFailure::class.java) + assertThat((outcome as BuildOutcome.DeployFailure).message) + .contains("NullPointerException") + } + + @Test + fun `forced no-op rebuilds current sources and deploys a FRESH generation`() = + runTest { + store.value = 5 + tracker = GenerationTracker(store) + executor = + LiveReloadExecutorImpl( + daemon = daemon, + deploy = deploy, + layout = QuickBuildProjectLayout(projectRoot), + entryActivity = "com.example.MainActivity", + generations = tracker, + workDir = File(projectRoot, ".androidide/quickbuild"), + clock = { 1000L }, + ) + + val outcome = executor.execute(request(BuildRoute.NoOp, forced = true)) + + // A replay of generation 5 would be dropped by the runtime (strictly-newer + // rule); the forced redeploy must ship real artifacts at generation 6. + assertThat(outcome).isEqualTo(BuildOutcome.Success(6, 0)) + // Full re-seed: every source is recompiled, resources relinked. + val (all, changed) = daemon.compileCalls.single() + assertThat(changed).isEqualTo(all) + assertThat(daemon.relinkCalls).hasSize(1) + val call = deploy.calls.single() + assertThat(call.generation).isEqualTo(6) + assertThat(call.dexFile).isNotNull() + assertThat(call.arscFile).isNotNull() + } + + @Test + fun `forced no-op packages the FULL asset set - the classifier gave it no changed-set to derive one from`() = + runTest { + // A second asset alongside the one setUp() writes, so "the whole tree" is + // distinguishable from "whatever setUp() happened to leave lying around". + File(projectRoot, "app/src/main/assets/data/more.json").apply { + parentFile!!.mkdirs() + writeText("{}") + } + + executor.execute(request(BuildRoute.NoOp, forced = true)) + + val call = deploy.calls.single() + assertThat(call.assetsZip).isNotNull() + ZipFile(call.assetsZip!!).use { zip -> + assertThat(zip.entries().toList().map { it.name }) + .containsExactly("data/levels.json", "data/more.json") + } + } + + @Test + fun `forced no-op with a broken resource maps to CompileError and burns no generation`() = + runTest { + val diagnostics = + listOf(BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "invalid color")) + daemon.relinkReply = DaemonReply.BuildFailed(diagnostics) + + val outcome = executor.execute(request(BuildRoute.NoOp, forced = true)) + + assertThat(outcome).isEqualTo(BuildOutcome.CompileError(diagnostics)) + assertThat(deploy.calls).isEmpty() + assertThat(tracker.current).isEqualTo(0) + } + + @Test + fun `forced no-op with a broken source maps to CompileError and burns no generation`() = + runTest { + val diagnostics = + listOf(BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "unresolved reference")) + daemon.compileReply = DaemonReply.BuildFailed(diagnostics) + + val outcome = executor.execute(request(BuildRoute.NoOp, forced = true)) + + assertThat(outcome).isEqualTo(BuildOutcome.CompileError(diagnostics)) + assertThat(deploy.calls).isEmpty() + assertThat(tracker.current).isEqualTo(0) + } + + @Test + fun `unforced no-op does nothing`() = + runTest { + val outcome = executor.execute(request(BuildRoute.NoOp, forced = false)) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(0, 0)) + assertThat(deploy.calls).isEmpty() + assertThat(daemon.compileCalls).isEmpty() + } + + @Test + fun `Unknown changes recompile everything - IC re-seed`() = + runTest { + val outcome = executor.execute(request(BuildRoute.CodeAndResources, ChangedFiles.Unknown)) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0)) + val (all, changed) = daemon.compileCalls.single() + assertThat(changed).isEqualTo(all) + assertThat(all).containsExactly(sourceFile) + } + + /** Returns 10, 20, 30, ... on each call - so t0 Long { + var t = 0L + return { + t += 10 + t + } + } + + /** + * Captures the per-generation timeline off the metrics sink - the executor's only + * programmatic outlet for it, since the log line is not observable from a test. + */ + private fun capturingMetrics(emitted: MutableList): QuickBuildMetricsSink = + object : QuickBuildMetricsSink by QuickBuildMetricsSink.Noop { + override fun onReloadTimeline(timeline: E2eTimeline) { + emitted += timeline + } + } + + private fun timingExecutor(emitted: MutableList): LiveReloadExecutorImpl = + LiveReloadExecutorImpl( + daemon = daemon, + deploy = deploy, + layout = QuickBuildProjectLayout(projectRoot), + entryActivity = "com.example.MainActivity", + generations = tracker, + workDir = File(projectRoot, ".androidide/quickbuild"), + clock = steppingClock(), + metrics = capturingMetrics(emitted), + ) + + private fun timedRequest( + route: BuildRoute, + changes: ChangedFiles, + triggeredAtMillis: Long, + ) = BuildRequest(buildId = 1, changes = changes, route = route, triggeredAtMillis = triggeredAtMillis) + + @Test + fun `a hot-swap deploy emits one e2e timeline with t0 from the request and t1-t3 from the clock`() = + runTest { + val emitted = mutableListOf() + val executor = timingExecutor(emitted) + + executor.execute(timedRequest(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)), triggeredAtMillis = 5)) + + // Clock order: startedAt=10, then the four span boundaries (20 scan start, 30 + // scan done, 40 compile done, 50 policy done, 60 dex done = compileDone), + // deploySent=70, reloadLive=80. + val t = emitted.single() + assertThat(t.generation).isEqualTo(1) + assertThat(t.trigger).isEqualTo(5) + assertThat(t.compileDone).isEqualTo(60) + assertThat(t.deploySent).isEqualTo(70) + assertThat(t.reloadLive).isEqualTo(80) + assertThat(t.compileMillis).isEqualTo(55) // trigger(5) -> compiled+dexed(60) + assertThat(t.reloadMillis).isEqualTo(10) // deploySent(70) -> live(80) + } + + @Test + fun `the host spans partition the build and abut with no gap of their own`() = + runTest { + val emitted = mutableListOf() + + timingExecutor(emitted).execute( + timedRequest(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)), triggeredAtMillis = 5), + ) + + // Each span is one 10 ms clock tick with the stepping clock, and consecutive + // spans share a boundary read - so the build's own spans cover [20, 60] exactly. + // The queue span sits before them, from t0 to this build's start. + val spans = emitted.single().spans!! + assertThat(spans.queueMillis).isEqualTo(5) // trigger(5) -> startedAt(10) + assertThat(spans.scanMillis).isEqualTo(10) + assertThat(spans.compileRpcMillis).isEqualTo(10) + assertThat(spans.policyMillis).isEqualTo(10) + assertThat(spans.dexRpcMillis).isEqualTo(10) + assertThat(spans.relinkRpcMillis).isNull() // no resources on this route + assertThat(spans.totalMillis).isEqualTo(45) + } + + @Test + fun `the residual names the time no span measured, and it stays small`() = + runTest { + val emitted = mutableListOf() + + timingExecutor(emitted).execute( + timedRequest(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)), triggeredAtMillis = 5), + ) + + val t = emitted.single() + // total 75 = spans 45 (queue 5 + the build's 40) + reload 10 + 20 of un-timed + // edges: the startedAt->scan lead (asset packaging) and the dex->deploy tail. + // Naming the queue moved 5 ms out of the residual and into a span the reader can + // act on, which is the whole point of measuring it. Every millisecond is either + // inside a named span or inside the residual - never silently attributed elsewhere. + assertThat(t.totalMillis).isEqualTo(75) + assertThat(t.accountedMillis).isEqualTo(55) + assertThat(t.unaccountedMillis).isEqualTo(20) + assertThat(t.accountedMillis + t.unaccountedMillis).isEqualTo(t.totalMillis) + } + + @Test + fun `a resources route accounts through its relink span`() = + runTest { + val emitted = mutableListOf() + + timingExecutor(emitted).execute( + timedRequest(BuildRoute.ResourcesOnly, ChangedFiles.Known(setOf(resFile)), triggeredAtMillis = 5), + ) + + val t = emitted.single() + assertThat(t.spans!!.relinkRpcMillis).isEqualTo(10) + assertThat(t.spans!!.compileRpcMillis).isNull() // nothing compiled + assertThat(t.accountedMillis + t.unaccountedMillis).isEqualTo(t.totalMillis) + } + + @Test + fun `daemon counts and the scratch filesystem ride along with the timing`() = + runTest { + daemon.scratchFsType = "fuse" + daemon.compileReply = + DaemonReply.Ok( + CompileOutput( + File("/fake/classes"), + changedClassFiles = emptyList(), + stats = + CompileStats( + preSnapMillis = 120, + postSnapMillis = 130, + javaAbiSnapMillis = 540, + allSources = 292, + kotlinToCompile = 74, + javaSources = 218, + changedClasses = 323, + compileOrdinal = 3, + ), + ), + ) + daemon.dexReply = + DaemonReply.Ok( + DexOutput(File("/fake/classes.dex"), stats = DexStats(classFiles = 464, classBytes = 1_530_112)), + ) + val emitted = mutableListOf() + + timingExecutor(emitted).execute( + timedRequest(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)), triggeredAtMillis = 5), + ) + + val t = emitted.single() + assertThat(t.counts) + .isEqualTo( + E2eTimeline.BuildCounts( + allSources = 292, + kotlinDeclaredChanged = 74, + javaSources = 218, + changedClasses = 323, + classFiles = 464, + classBytes = 1_530_112, + compileOrdinal = 3, + ), + ) + assertThat(t.steps!!.preSnapMillis).isEqualTo(120) + assertThat(t.steps!!.postSnapMillis).isEqualTo(130) + assertThat(t.steps!!.javaAbiSnapMillis).isEqualTo(540) + assertThat(t.scratchFsType).isEqualTo("fuse") + } + + @Test + fun `a daemon reporting no stats leaves the counts absent rather than zeroed`() = + runTest { + val emitted = mutableListOf() + + timingExecutor(emitted).execute( + timedRequest(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)), triggeredAtMillis = 5), + ) + + // A zero-filled row would read as "measured, and the build did nothing". + assertThat(emitted.single().counts).isNull() + assertThat(emitted.single().scratchFsType).isNull() + } + + @Test + fun `daemon step timings thread through to the emitted timeline`() = + runTest { + daemon.compileReply = + DaemonReply.Ok( + CompileOutput( + File("/fake/classes"), + changedClassFiles = emptyList(), + kotlinMillis = 400, + javaMillis = 50, + ), + ) + daemon.dexReply = DaemonReply.Ok(DexOutput(File("/fake/classes.dex"), stripMillis = 20, d8Millis = 150)) + daemon.relinkReply = + DaemonReply.Ok(RelinkOutput(File("/fake/resources.arsc"), aapt2CompileMillis = 80, aapt2LinkMillis = 120)) + val emitted = mutableListOf() + val executor = timingExecutor(emitted) + + executor.execute( + timedRequest( + BuildRoute.CodeAndResources, + ChangedFiles.Known(setOf(sourceFile, resFile)), + triggeredAtMillis = 5, + ), + ) + + assertThat(emitted.single().steps) + .isEqualTo( + E2eTimeline.StepTimings( + kotlinMillis = 400, + javaMillis = 50, + stripMillis = 20, + d8Millis = 150, + aapt2CompileMillis = 80, + aapt2LinkMillis = 120, + ), + ) + } + + @Test + fun `a resource-only deploy has no compile phase - compileDone folds into deploySent`() = + runTest { + val emitted = mutableListOf() + val executor = timingExecutor(emitted) + + executor.execute( + timedRequest(BuildRoute.ResourcesOnly, ChangedFiles.Known(setOf(resFile)), triggeredAtMillis = 5), + ) + + // No markCompileDone call: startedAt=10, relink spans [20,30], deploySent=40, + // reloadLive=50. compileDone falls back to deploySent. + val t = emitted.single() + assertThat(t.compileDone).isEqualTo(40) + assertThat(t.deploySent).isEqualTo(40) + assertThat(t.reloadLive).isEqualTo(50) + assertThat(t.stageMillis).isEqualTo(0) + assertThat(t.compileMillis).isEqualTo(35) // relink + package land in compileMillis here + } + + @Test + fun `a restart deploy emits its timeline only after the reconnect is verified`() = + runTest { + val emitted = mutableListOf() + val launcher = FakeLauncher() + val executor = + LiveReloadExecutorImpl( + daemon = daemon, + deploy = deploy, + layout = QuickBuildProjectLayout(projectRoot), + entryActivity = "com.example.MainActivity", + generations = tracker, + workDir = File(projectRoot, ".androidide/quickbuild"), + deployPolicy = + DeployPolicy(listOf(ComponentInfo(ComponentKind.SERVICE, "com.example.SyncService"))), + proxyAppPackage = "com.example.quickbuild", + launcherActivity = "com.example.quickbuild.proxies.Proxy0Activity", + launcher = launcher, + clock = steppingClock(), + metrics = capturingMetrics(emitted), + ) + serviceRecompiled() + + val outcome = + executor.execute( + timedRequest(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)), triggeredAtMillis = 5), + ) + + // Clock: startedAt=10, span boundaries 20..60 (compileDone=60), deploySent=70, + // reloadLive=80 (after the verified reconnect). The reported duration is that same + // t3 minus t0 (80 - 5), off one clock read rather than a second later one, so it + // cannot drift past the loop it describes. + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 75, restarted = true)) + val t = emitted.single() + assertThat(t.compileDone).isEqualTo(60) + assertThat(t.deploySent).isEqualTo(70) + assertThat(t.reloadLive).isEqualTo(80) + // The number the user reads is the timeline's own total, which is what made the two + // Build Output lines reconcilable (manual QA, 2026-08-11). + assertThat((outcome as BuildOutcome.Success).durationMillis).isEqualTo(t.totalMillis) + } + + @Test + fun `a compile error emits no timeline - nothing reloaded`() = + runTest { + val emitted = mutableListOf() + daemon.compileReply = + DaemonReply.BuildFailed(listOf(BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "boom"))) + + timingExecutor(emitted).execute( + timedRequest(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)), triggeredAtMillis = 5), + ) + + assertThat(emitted).isEmpty() + } + + @Test + fun `a warm-compile build emits no timeline - nothing reloaded`() = + runTest { + val emitted = mutableListOf() + + timingExecutor(emitted).execute( + timedRequest(BuildRoute.WarmCompile, ChangedFiles.Unknown, triggeredAtMillis = 5), + ) + + assertThat(emitted).isEmpty() + } + + @Test + fun `a failed deploy emits no timeline - the reload never landed`() = + runTest { + val emitted = mutableListOf() + deploy.result = DeployResult.TimedOut(15_000) + + timingExecutor(emitted).execute( + timedRequest(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)), triggeredAtMillis = 5), + ) + + assertThat(emitted).isEmpty() + } + + private class RecordingMetrics : org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink { + val timelines = mutableListOf() + + override fun onSessionStarted() = Unit + + override fun onBuildStarted( + buildId: Long, + route: BuildRoute, + changes: ChangedFiles, + ) = Unit + + override fun onBuildFinished( + buildId: Long, + outcome: BuildOutcome, + ) = Unit + + override fun onInvalidation(reason: InvalidationReason) = Unit + + override fun onProxyAppRebuild( + isSuccess: Boolean, + durationMillis: Long, + relaunchOk: Boolean, + toRunningMillis: Long?, + ) = Unit + + override fun onReloadTimeline(timeline: E2eTimeline) { + timelines += timeline + } + } + + @Test + fun `a successful deploy reports the timeline to the analytics sink exactly once`() = + runTest { + val metrics = RecordingMetrics() + val executor = + LiveReloadExecutorImpl( + daemon = daemon, + deploy = deploy, + layout = QuickBuildProjectLayout(projectRoot), + entryActivity = "com.example.MainActivity", + generations = tracker, + workDir = File(projectRoot, ".androidide/quickbuild"), + clock = steppingClock(), + metrics = metrics, + ) + + executor.execute(timedRequest(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)), triggeredAtMillis = 5)) + + assertThat(metrics.timelines).hasSize(1) + assertThat(metrics.timelines.single().trigger).isEqualTo(5) + } + + @Test + fun `a failed deploy reports no timeline to analytics - nothing reached the user`() = + runTest { + val metrics = RecordingMetrics() + val executor = + LiveReloadExecutorImpl( + daemon = daemon, + deploy = deploy, + layout = QuickBuildProjectLayout(projectRoot), + entryActivity = "com.example.MainActivity", + generations = tracker, + workDir = File(projectRoot, ".androidide/quickbuild"), + clock = steppingClock(), + metrics = metrics, + ) + deploy.result = DeployResult.TimedOut(15_000) + + executor.execute(timedRequest(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)), triggeredAtMillis = 5)) + + assertThat(metrics.timelines).isEmpty() + } + + @Test + fun `a throwing analytics sink never fails a build the user already saw reload`() = + runTest { + val throwingMetrics = + object : org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink { + override fun onSessionStarted() = Unit + + override fun onBuildStarted( + buildId: Long, + route: BuildRoute, + changes: ChangedFiles, + ) = Unit + + override fun onBuildFinished( + buildId: Long, + outcome: BuildOutcome, + ) = Unit + + override fun onInvalidation(reason: InvalidationReason) = Unit + + override fun onProxyAppRebuild( + isSuccess: Boolean, + durationMillis: Long, + relaunchOk: Boolean, + toRunningMillis: Long?, + ) = Unit + + override fun onReloadTimeline(timeline: E2eTimeline): Unit = throw RuntimeException("sink boom") + } + val executor = + LiveReloadExecutorImpl( + daemon = daemon, + deploy = deploy, + layout = QuickBuildProjectLayout(projectRoot), + entryActivity = "com.example.MainActivity", + generations = tracker, + workDir = File(projectRoot, ".androidide/quickbuild"), + clock = { 1000L }, + metrics = throwingMetrics, + ) + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + // The sink threw inside reportTimeline but the guard swallowed it: the build the + // user already saw reload still reports Success. + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0)) + } + + private class FakeLauncher( + var result: Boolean = true, + ) : ProxyAppLauncher { + val calls = mutableListOf>() + + /** Per-attempt override of [result]; the argument is the 1-based attempt number. */ + var resultFor: ((attempt: Int) -> Boolean)? = null + + override fun launch( + packageName: String, + activityClass: String?, + ): Boolean { + calls += packageName to activityClass + return resultFor?.invoke(calls.size) ?: result + } + } + + private fun restartExecutor( + launcher: FakeLauncher, + launcherActivity: String? = "com.example.quickbuild.proxies.Proxy0Activity", + policy: DeployPolicy = + DeployPolicy( + listOf( + ComponentInfo( + ComponentKind.ACTIVITY, + "com.example.MainActivity", + proxyClass = "com.example.quickbuild.proxies.Proxy0Activity", + launcher = true, + ), + ComponentInfo(ComponentKind.SERVICE, "com.example.SyncService"), + ), + ), + ) = LiveReloadExecutorImpl( + daemon = daemon, + deploy = deploy, + layout = QuickBuildProjectLayout(projectRoot), + entryActivity = "com.example.MainActivity", + generations = tracker, + workDir = File(projectRoot, ".androidide/quickbuild"), + deployPolicy = policy, + proxyAppPackage = "com.example.quickbuild", + launcherActivity = launcherActivity, + launcher = launcher, + clock = { 1000L }, + ) + + private fun serviceRecompiled() { + daemon.compileReply = + DaemonReply.Ok( + org.appdevforall.cotg.quickbuild.data + .CompileOutput(File("/fake/classes"), listOf("com/example/SyncService.class")), + ) + } + + @Test + fun `service edit deploys with restart metadata, awaits the exit and relaunches`() = + runTest { + val launcher = FakeLauncher() + val executor = restartExecutor(launcher) + serviceRecompiled() + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0, restarted = true)) + val call = deploy.calls.single() + assertThat(metadataOf(call).get("restart").asString).isEqualTo("true") + assertThat(deploy.awaitDisconnectCalls).hasSize(1) + assertThat(launcher.calls) + .containsExactly("com.example.quickbuild" to "com.example.quickbuild.proxies.Proxy0Activity") + } + + @Test + fun `restart relaunches by package when the launcher is an activity-alias (no launcher activity)`() = + runTest { + val launcher = FakeLauncher() + // launcherActivity null models a MAIN/LAUNCHER filter on an : + // no proxied activity carries launcher=true, so the relaunch must fall back to + // the package's default launch intent (activityClass = null) rather than fail. + val executor = restartExecutor(launcher, launcherActivity = null) + serviceRecompiled() + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0, restarted = true)) + assertThat(launcher.calls).containsExactly("com.example.quickbuild" to null) + } + + @Test + fun `helper-only edit restarts too - the payload redefines the service either way`() = + runTest { + // This edit names nothing the service inherits from, and the whole pipeline still + // has to take the restart route: the dex it ships carries the service class. + val launcher = FakeLauncher() + val executor = restartExecutor(launcher) + daemon.compileReply = + DaemonReply.Ok( + org.appdevforall.cotg.quickbuild.data + .CompileOutput(File("/fake/classes"), listOf("com/example/util/Formatter.class")), + ) + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0, restarted = true)) + assertThat(metadataOf(deploy.calls.single()).get("restart").asString).isEqualTo("true") + assertThat(deploy.awaitDisconnectCalls).isNotEmpty() + assertThat(launcher.calls) + .containsExactly("com.example.quickbuild" to "com.example.quickbuild.proxies.Proxy0Activity") + } + + @Test + fun `helper-only edit hot-swaps when no service, provider or Application is declared`() = + runTest { + // The negative control: identical edit and pipeline, an activity-only component + // list. Without this the test above would pass on a policy that restarts always. + val launcher = FakeLauncher() + val executor = + restartExecutor( + launcher, + policy = + DeployPolicy( + listOf( + ComponentInfo( + ComponentKind.ACTIVITY, + "com.example.MainActivity", + proxyClass = "com.example.quickbuild.proxies.Proxy0Activity", + launcher = true, + ), + ), + ), + ) + daemon.compileReply = + DaemonReply.Ok( + org.appdevforall.cotg.quickbuild.data + .CompileOutput(File("/fake/classes"), listOf("com/example/util/Formatter.class")), + ) + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0)) + assertThat(metadataOf(deploy.calls.single()).has("restart")).isFalse() + assertThat(deploy.awaitDisconnectCalls).isEmpty() + assertThat(launcher.calls).isEmpty() + } + + @Test + fun `restart deploy that disconnects before acking succeeds once the relaunch reconnects at the new generation`() = + runTest { + val launcher = FakeLauncher() + val executor = restartExecutor(launcher) + serviceRecompiled() + deploy.result = DeployResult.Disconnected + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0, restarted = true)) + assertThat(launcher.calls).hasSize(1) + // Success was VERIFIED against the reconnect, not assumed. + assertThat(deploy.awaitReconnectCalls).hasSize(1) + } + + @Test + fun `restart relaunch reconnecting at an older generation routes to a proxy app rebuild - the payload did not persist`() = + runTest { + val launcher = FakeLauncher() + val executor = restartExecutor(launcher) + serviceRecompiled() + // The process died around the payload and the fresh boot came back on the + // previous generation: claiming success would be the silent-stale lie. + deploy.reconnectGeneration = { 0L } + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isInstanceOf(BuildOutcome.RequiresProxyAppRebuild::class.java) + assertThat((outcome as BuildOutcome.RequiresProxyAppRebuild).reason) + .isEqualTo(InvalidationReason.OUTDATED_BASELINE) + assertThat(outcome.detail).contains("generation 0") + } + + @Test + fun `restart relaunch that never reconnects is a deploy failure, after a second try`() = + runTest { + val launcher = FakeLauncher() + val executor = restartExecutor(launcher) + serviceRecompiled() + deploy.reconnectGeneration = { null } + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isInstanceOf(BuildOutcome.DeployFailure::class.java) + val message = (outcome as BuildOutcome.DeployFailure).message + assertThat(message).contains("did not come back") + // The launch call cannot tell a start Android blocked from one that worked, so the + // message must report what we know - the app never came back - and must not assert + // a relaunch that may never have happened. + assertThat(message).doesNotContain("was relaunched") + // Two attempts, and no more: a dead app has to reach the user rather than become a + // retry storm. + assertThat(launcher.calls).hasSize(2) + assertThat(deploy.awaitReconnectCalls).hasSize(2) + } + + @Test + fun `a relaunch swallowed by the dead task is recovered by the second one`() = + runTest { + // The measured defect: the first start is handed to the killed process's own + // activity record and dropped, so nothing comes back. The second start finds no + // task and creates one. Without the retry this build ends at "open it manually". + val launcher = FakeLauncher() + val executor = restartExecutor(launcher) + serviceRecompiled() + var attempt = 0 + deploy.reconnectGeneration = { deployed -> + attempt++ + if (attempt == 1) null else deployed + } + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0, restarted = true)) + assertThat(launcher.calls).hasSize(2) + } + + @Test + fun `a first relaunch that reconnects is not retried`() = + runTest { + val launcher = FakeLauncher() + val executor = restartExecutor(launcher) + serviceRecompiled() + + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(launcher.calls).hasSize(1) + assertThat(deploy.awaitReconnectCalls).hasSize(1) + } + + @Test + fun `a second relaunch that cannot even start is not waited on`() = + runTest { + // The launcher refusing outright is not the swallowed-start case: nothing ran, so + // another reconnect wait would just be 15 s of silence for the user. + val launcher = FakeLauncher().apply { resultFor = { attempt -> attempt == 1 } } + val executor = restartExecutor(launcher) + serviceRecompiled() + deploy.reconnectGeneration = { null } + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isInstanceOf(BuildOutcome.DeployFailure::class.java) + assertThat(launcher.calls).hasSize(2) + assertThat(deploy.awaitReconnectCalls).hasSize(1) + } + + @Test + fun `restart ack without a process exit routes to a proxy app rebuild - old runtime hot-swapped`() = + runTest { + val launcher = FakeLauncher() + val executor = restartExecutor(launcher) + serviceRecompiled() + deploy.disconnects = false + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isInstanceOf(BuildOutcome.RequiresProxyAppRebuild::class.java) + assertThat((outcome as BuildOutcome.RequiresProxyAppRebuild).reason) + .isEqualTo(InvalidationReason.OUTDATED_BASELINE) + assertThat(launcher.calls).isEmpty() + } + + @Test + fun `failed relaunch is a deploy failure telling the user to reopen the app`() = + runTest { + val launcher = FakeLauncher(result = false) + val executor = restartExecutor(launcher) + serviceRecompiled() + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isInstanceOf(BuildOutcome.DeployFailure::class.java) + assertThat((outcome as BuildOutcome.DeployFailure).message).contains("relaunched") + } + + @Test + fun `pre-v2 baseline refuses a code deploy BEFORE deploying - proxy app rebuild instead`() = + runTest { + val launcher = FakeLauncher() + val executor = + restartExecutor(launcher, policy = DeployPolicy(emptyList(), componentInfoAvailable = false)) + daemon.compileReply = + DaemonReply.Ok( + org.appdevforall.cotg.quickbuild.data + .CompileOutput(File("/fake/classes"), listOf("com/example/Foo.class")), + ) + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isInstanceOf(BuildOutcome.RequiresProxyAppRebuild::class.java) + assertThat(deploy.calls).isEmpty() + assertThat(tracker.current).isEqualTo(0) + } + + @Test + fun `unknown recompiled set with a service restarts conservatively`() = + runTest { + val launcher = FakeLauncher() + val executor = restartExecutor(launcher) + daemon.compileReply = + DaemonReply.Ok( + org.appdevforall.cotg.quickbuild.data + .CompileOutput(File("/fake/classes"), changedClassFiles = null), + ) + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0, restarted = true)) + assertThat(metadataOf(deploy.calls.single()).get("restart").asString).isEqualTo("true") + } + + @Test + fun `resource-only deploys never restart even with a service present`() = + runTest { + val launcher = FakeLauncher() + val executor = restartExecutor(launcher) + + val outcome = + executor.execute(request(BuildRoute.ResourcesOnly, ChangedFiles.Known(setOf(resFile)))) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0)) + assertThat(metadataOf(deploy.calls.single()).has("restart")).isFalse() + assertThat(launcher.calls).isEmpty() + } + + /** + * Executor wired the way a real session is (launcher + package known) but with no + * restart-forcing policy, so deploys hot-swap: the defect-#88 surface, where a + * proxy app rebuild reinstall killed the proxy app and the next deploy finds NotConnected. + */ + private fun relaunchExecutor( + launcher: FakeLauncher, + reconnectTimeoutMillis: Long = 15_000L, + ) = LiveReloadExecutorImpl( + daemon = daemon, + deploy = deploy, + layout = QuickBuildProjectLayout(projectRoot), + entryActivity = "com.example.MainActivity", + generations = tracker, + workDir = File(projectRoot, ".androidide/quickbuild"), + proxyAppPackage = "com.example.quickbuild", + launcherActivity = "com.example.quickbuild.proxies.Proxy0Activity", + launcher = launcher, + restartReconnectTimeoutMillis = reconnectTimeoutMillis, + clock = { 1000L }, + ) + + @Test + fun `NotConnected deploy relaunches the proxy app, awaits the rebind and retries exactly once - defect 88`() = + runTest { + val launcher = FakeLauncher() + val executor = relaunchExecutor(launcher) + // First attempt hits the post-reinstall dead connection; the retry (default + // result) lands. + deploy.resultQueue += DeployResult.NotConnected + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0)) + assertThat(deploy.calls).hasSize(2) + // Same payload both times: the first attempt never reached the app. + assertThat(deploy.calls[0].generation).isEqualTo(deploy.calls[1].generation) + assertThat(launcher.calls) + .containsExactly("com.example.quickbuild" to "com.example.quickbuild.proxies.Proxy0Activity") + assertThat(deploy.awaitReconnectCalls).hasSize(1) + } + + @Test + fun `NotConnected RESTART deploy recovers too - relaunch, rebind, one retry, then the restart sequence`() = + runTest { + // The other half of the defect-88 surface: a service/receiver/provider edit + // after the proxy app rebuild reinstall deploys through deployRestart, which must + // route through the same recovery as the hot-swap path. + val launcher = FakeLauncher() + val executor = restartExecutor(launcher) + serviceRecompiled() + // First attempt hits the post-reinstall dead connection; the retried deploy + // (default result) acks, and the normal restart sequence follows. + deploy.resultQueue += DeployResult.NotConnected + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0, restarted = true)) + // Same restart payload both times: the first attempt never reached the app. + assertThat(deploy.calls).hasSize(2) + assertThat(deploy.calls[0].generation).isEqualTo(deploy.calls[1].generation) + deploy.calls.forEach { call -> + assertThat(metadataOf(call).get("restart").asString).isEqualTo("true") + } + // One launch for the recovery rebind, one for the restart relaunch itself; + // likewise one reconnect wait each. + assertThat(launcher.calls).hasSize(2) + assertThat(deploy.awaitReconnectCalls).hasSize(2) + assertThat(deploy.awaitDisconnectCalls).hasSize(1) + } + + @Test + fun `a connected proxy app deploys with no relaunch and no rebind wait`() = + runTest { + val launcher = FakeLauncher() + val executor = relaunchExecutor(launcher) + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 0)) + assertThat(deploy.calls).hasSize(1) + assertThat(launcher.calls).isEmpty() + assertThat(deploy.awaitReconnectCalls).isEmpty() + } + + @Test + fun `still NotConnected after the one retry keeps the failure with the relaunch remedy - no third attempt`() = + runTest { + val launcher = FakeLauncher() + val executor = relaunchExecutor(launcher) + deploy.result = DeployResult.NotConnected // both attempts fail + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + // A plain DeployFailure: the reducer keeps the session Ready on it (no + // teardown, no proxy app rebuild), so the next save just tries again. + assertThat(outcome).isInstanceOf(BuildOutcome.DeployFailure::class.java) + assertThat((outcome as BuildOutcome.DeployFailure).message).contains("Tap Quick Build to start it") + assertThat(deploy.calls).hasSize(2) + assertThat(launcher.calls).hasSize(1) + } + + @Test + fun `rebind wait is bounded by the injected reconnect timeout and a timeout skips the retry`() = + runTest { + val launcher = FakeLauncher() + val executor = relaunchExecutor(launcher, reconnectTimeoutMillis = 1_234) + deploy.result = DeployResult.NotConnected + deploy.reconnectGeneration = { null } // app never rebinds within the bound + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isInstanceOf(BuildOutcome.DeployFailure::class.java) + // Retrying against a still-dead connection would just double the wait. + assertThat(deploy.calls).hasSize(1) + assertThat(deploy.awaitReconnectCalls).containsExactly(1_234L) + } + + @Test + fun `a relaunch that cannot even start skips the rebind wait and keeps the failure`() = + runTest { + val launcher = FakeLauncher(result = false) + val executor = relaunchExecutor(launcher) + deploy.result = DeployResult.NotConnected + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isInstanceOf(BuildOutcome.DeployFailure::class.java) + assertThat((outcome as BuildOutcome.DeployFailure).message).contains("Tap Quick Build to start it") + assertThat(deploy.calls).hasSize(1) + assertThat(deploy.awaitReconnectCalls).isEmpty() + } + + @Test + fun `NotConnected with no launcher wired fails on the first attempt but still names the remedy`() = + runTest { + // The default executor from setUp has no launcher/package (pre-#88 wiring). + deploy.result = DeployResult.NotConnected + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isInstanceOf(BuildOutcome.DeployFailure::class.java) + assertThat((outcome as BuildOutcome.DeployFailure).message).contains("Tap Quick Build to start it") + assertThat(deploy.calls).hasSize(1) + } + + @Test + fun `disconnect during a NORMAL deploy is a deploy failure`() = + runTest { + deploy.result = DeployResult.Disconnected + + val outcome = + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(outcome).isInstanceOf(BuildOutcome.DeployFailure::class.java) + assertThat((outcome as BuildOutcome.DeployFailure).message).contains("disconnected") + } + + @Test + fun `FullGradleBuild route is refused as an infrastructure failure`() = + runTest { + val outcome = + executor.execute( + request( + BuildRoute.FullGradleBuild( + org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason.MANIFEST_CHANGED, + ), + ), + ) + + assertThat(outcome).isInstanceOf(BuildOutcome.InfrastructureFailure::class.java) + assertThat(deploy.calls).isEmpty() + } + + /** + * Builds an executor wired to a launcher, so the deploy pipeline can actually reach + * the launch decision. The rest of the suite leaves the launcher null, which makes + * [org.appdevforall.cotg.quickbuild.service.deploy.PayloadDeployer] bail before the decision and hides the wiring these three tests + * cover. + */ + private fun launchableExecutor(): LiveReloadExecutorImpl = + LiveReloadExecutorImpl( + daemon = daemon, + deploy = deploy, + layout = QuickBuildProjectLayout(projectRoot), + entryActivity = "com.example.MainActivity", + generations = tracker, + workDir = File(projectRoot, ".androidide/quickbuild"), + proxyAppPackage = "com.example.app", + launcher = + ProxyAppLauncher { packageName, activityClass -> + launchCalls += packageName to activityClass + true + }, + clock = { 1000L }, + ) + + /** + * The seeding half of the gate: `execute` copies the request's ask onto the flag the + * deployer reads, so a save's failed deploy must never open the app. + * + * Pins the mutation "seed the flag to true" - which is the shipped bug this feature + * fixed - at the executor, where [PayloadDeployerTest] cannot see it. + */ + @Test + fun `a save's deploy to a closed app does not launch it`() = + runTest { + deploy.result = DeployResult.NotConnected + val executor = launchableExecutor() + + val outcome = + executor.execute( + request( + BuildRoute.CodeOnly, + ChangedFiles.Known(setOf(sourceFile)), + userInitiated = false, + ), + ) + + assertThat(launchCalls).isEmpty() + assertThat(deploy.calls).hasSize(1) + val failure = outcome as BuildOutcome.DeployFailure + assertThat(failure.proxyAppNotConnected).isFalse() + } + + /** + * The promotion half: a tap landing mid-build must change the launch decision of the + * build already in flight, which is why the deployer reads the flag live rather than + * capturing it. Pins two mutations - dropping the `markCurrentBuildUserInitiated` + * override (the interface default is a no-op, so everything else stays green), and + * snapshotting `userInitiated()` at deploy entry. + */ + @Test + fun `a tap landing mid-build promotes it, so its deploy opens the closed app`() = + runTest { + val executor = launchableExecutor() + // Fires while the build is between compile and deploy, which is exactly when a + // real tap lands: the request was a save, so only the promotion can open the app. + daemon.onCompile = { executor.markCurrentBuildUserInitiated() } + deploy.result = DeployResult.NotConnected + + val outcome = + executor.execute( + request( + BuildRoute.CodeOnly, + ChangedFiles.Known(setOf(sourceFile)), + userInitiated = false, + ), + ) + + assertThat(launchCalls).containsExactly("com.example.app" to null) + val failure = outcome as BuildOutcome.DeployFailure + // Launched and still absent - the honest cannot-stay-up evidence. + assertThat(failure.proxyAppNotConnected).isTrue() + } + + /** + * The reseed half: the promotion belongs to the build that was promoted, not to the + * session. Without the per-request reseed the flag latches true and every later save + * opens the app. + */ + @Test + fun `a promotion does not carry over to the next save`() = + runTest { + val executor = launchableExecutor() + daemon.onCompile = { executor.markCurrentBuildUserInitiated() } + deploy.result = DeployResult.NotConnected + executor.execute( + request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)), userInitiated = false), + ) + assertThat(launchCalls).hasSize(1) + + daemon.onCompile = {} + val outcome = + executor.execute( + request( + BuildRoute.CodeOnly, + ChangedFiles.Known(setOf(sourceFile)), + userInitiated = false, + ), + ) + + assertThat(launchCalls).hasSize(1) + assertThat((outcome as BuildOutcome.DeployFailure).proxyAppNotConnected).isFalse() + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/LiveSessionAdoptBaselineTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/LiveSessionAdoptBaselineTest.kt new file mode 100644 index 0000000000..8d4092449e --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/LiveSessionAdoptBaselineTest.kt @@ -0,0 +1,191 @@ +@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + +package org.appdevforall.cotg.quickbuild.service.session + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.data.ProjectWatcher +import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo +import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.annotations.AnnotationImpact +import org.appdevforall.cotg.quickbuild.domain.annotations.SwitchableAnnotationImpact +import org.appdevforall.cotg.quickbuild.domain.classify.ChangeClassifier +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.reload.BuildRequest +import org.appdevforall.cotg.quickbuild.domain.reload.GenerationTracker +import org.appdevforall.cotg.quickbuild.domain.reload.LiveReloadExecutor +import org.appdevforall.cotg.quickbuild.domain.reload.LiveReloadOrchestrator +import org.appdevforall.cotg.quickbuild.domain.watch.WatchFilter +import org.appdevforall.cotg.quickbuild.service.MemoryGenerationStore +import org.appdevforall.cotg.quickbuild.service.deploy.RetainedPayloadStore +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** + * Every ProxyAppInfo-derived piece of a session moves to the new baseline together. + * Leave one behind and the deploy policy keeps routing on provisioning-time facts - a + * service the rebuild just proxied would hot-swap and leave its live instance stale - + * which is invisible in a green build and only shows up as a wrong deploy on device. + */ +class LiveSessionAdoptBaselineTest { + @TempDir lateinit var projectRoot: File + + private class RecordingExecutor : LiveReloadExecutor { + val requests = mutableListOf() + var userInitiatedMarks = 0 + + override suspend fun execute(request: BuildRequest): BuildOutcome { + requests += request + return BuildOutcome.Success(generation = 1, durationMillis = 0) + } + + override fun markCurrentBuildUserInitiated() { + userInitiatedMarks++ + } + } + + private class NoopWatcher : ProjectWatcher { + override fun start(onBatch: (ChangedFiles.Known) -> Unit) = Unit + + override fun stop() = Unit + } + + private class FixedAnnotationImpact( + override val active: Boolean, + ) : AnnotationImpact { + override fun escalation(changedCodeFiles: List): String? = null + } + + private fun proxyApp(pkg: String) = + ProxyAppInfo( + proxyAppPackage = pkg, + entryActivity = "com.example.MainActivity", + apk = File(projectRoot, "proxy-app.apk"), + classpath = emptyList(), + proxyClassesDir = null, + transformedManifest = null, + schema = 2, + components = emptyList(), + annotationProcessors = emptyList(), + ) + + private fun session(scope: kotlinx.coroutines.CoroutineScope): LiveSession { + val executor = SwitchableExecutor(RecordingExecutor()) + return LiveSession( + proxyApp = proxyApp("com.example.old"), + layout = QuickBuildProjectLayout(projectRoot), + tracker = GenerationTracker(MemoryGenerationStore()), + filter = WatchFilter(listOf(projectRoot)), + orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), scope) {}, + watcher = NoopWatcher(), + executor = executor, + annotationImpact = SwitchableAnnotationImpact(FixedAnnotationImpact(active = false)), + retainedPayloads = RetainedPayloadStore.forWorkDir(File(projectRoot, "work")), + ) + } + + @Test + fun `adoptBaseline moves proxyApp, layout, both delegates and the deployed generation together`() = + runTest { + val session = session(backgroundScope) + session.lastDeployedGeneration = 7L + + val newLayout = QuickBuildProjectLayout(File(projectRoot, "rebuilt").apply { mkdirs() }) + val newExecutor = RecordingExecutor() + val newAnnotationImpact = FixedAnnotationImpact(active = true) + + session.adoptBaseline( + proxyApp("com.example.new"), + newLayout, + newExecutor, + newAnnotationImpact, + baselineGeneration = 9L, + ) + + assertThat(session.proxyApp.proxyAppPackage).isEqualTo("com.example.new") + assertThat(session.layout).isSameInstanceAs(newLayout) + assertThat(session.executor.delegate).isSameInstanceAs(newExecutor) + assertThat(session.annotationImpact.delegate).isSameInstanceAs(newAnnotationImpact) + // The reinstalled baseline boots at its stamp (9), so anything deployed to the + // old epoch (7) is gone and a reconnect at 9 reads in-sync. + assertThat(session.lastDeployedGeneration).isEqualTo(9L) + } + + @Test + fun `adoptBaseline drops the retained payload - the old baseline's bytes must not replay onto the new one`() = + runTest { + val session = session(backgroundScope) + val dex = File(projectRoot, "built.dex").apply { writeText("old-baseline-dex") } + session.retainedPayloads.retain(7L, dex, null, null, "{}") + + session.adoptBaseline( + proxyApp("com.example.new"), + QuickBuildProjectLayout(projectRoot), + RecordingExecutor(), + FixedAnnotationImpact(active = false), + baselineGeneration = 9L, + ) + + // A reconnect below the new baseline must fall through to the forced rebuild; + // re-sending retention from the old baseline would resurrect superseded code. + assertThat(session.retainedPayloads.load()).isNull() + } + + @Test + fun `markCurrentBuildUserInitiated reaches the delegate, before and after a baseline swap`() = + runTest { + val session = session(backgroundScope) + val oldExecutor = session.executor.delegate as RecordingExecutor + + session.executor.markCurrentBuildUserInitiated() + + // The interface gives this a no-op default, so a SwitchableExecutor that forgets to + // override it absorbs the call and the real executor never learns the build was + // promoted: the tap's deploy then refuses to relaunch a closed proxy app. + assertThat(oldExecutor.userInitiatedMarks).isEqualTo(1) + + val newExecutor = RecordingExecutor() + session.adoptBaseline( + proxyApp("com.example.new"), + QuickBuildProjectLayout(projectRoot), + newExecutor, + FixedAnnotationImpact(active = false), + baselineGeneration = 0L, + ) + session.executor.markCurrentBuildUserInitiated() + + assertThat(newExecutor.userInitiatedMarks).isEqualTo(1) + assertThat(oldExecutor.userInitiatedMarks).isEqualTo(1) + } + + @Test + fun `a batch held across the rebuild is released to the NEW executor, not the old one`() = + runTest { + val session = session(backgroundScope) + val oldExecutor = session.executor.delegate as RecordingExecutor + val changed = ChangedFiles.Known(setOf(File(projectRoot, "app/src/main/java/A.kt"))) + session.orchestrator.onProxyAppRebuildStarted() + session.orchestrator.onFilesChanged(changed) + runCurrent() + // Held, not built: the rebuild owns the device while it runs. + assertThat(oldExecutor.requests).isEmpty() + + val newExecutor = RecordingExecutor() + session.adoptBaseline( + proxyApp("com.example.new"), + QuickBuildProjectLayout(projectRoot), + newExecutor, + FixedAnnotationImpact(active = false), + baselineGeneration = 0L, + ) + runCurrent() + + // adoptBaseline has to release the hold; drop its onBaselineReset and the batch + // sits in pending forever, so the user's edit never builds after a rebuild. + assertThat(newExecutor.requests.single().changes).isEqualTo(changed) + assertThat(oldExecutor.requests).isEmpty() + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/LiveSessionFactoryTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/LiveSessionFactoryTest.kt new file mode 100644 index 0000000000..64bb8483ac --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/LiveSessionFactoryTest.kt @@ -0,0 +1,251 @@ +package org.appdevforall.cotg.quickbuild.service.session + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.data.CompileOutput +import org.appdevforall.cotg.quickbuild.data.DaemonReply +import org.appdevforall.cotg.quickbuild.data.DexOutput +import org.appdevforall.cotg.quickbuild.data.ProjectWatcher +import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo +import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout +import org.appdevforall.cotg.quickbuild.data.QuickBuildScratch +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.annotations.AnnotationImpact +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.reload.BuildRequest +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentInfo +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind +import org.appdevforall.cotg.quickbuild.domain.reload.GenerationTracker +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink +import org.appdevforall.cotg.quickbuild.service.FakeDaemon +import org.appdevforall.cotg.quickbuild.service.FakeDeploy +import org.appdevforall.cotg.quickbuild.service.FakePaths +import org.appdevforall.cotg.quickbuild.service.MemoryGenerationStore +import org.appdevforall.cotg.quickbuild.service.deploy.DeployResult +import org.appdevforall.cotg.quickbuild.service.provision.ProvisionOutcome +import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppLauncher +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class LiveSessionFactoryTest { + @TempDir lateinit var projectRoot: File + + private val daemon = FakeDaemon() + private val deploy = FakeDeploy() + private val launchCalls = mutableListOf>() + + private lateinit var sourceFile: File + + @BeforeEach + fun setUp() { + val mainDir = File(projectRoot, "app/src/main") + sourceFile = + File(mainDir, "java/com/example/Foo.kt").apply { + parentFile!!.mkdirs() + writeText("class Foo") + } + File(mainDir, "AndroidManifest.xml").writeText("") + } + + private fun factory( + watcherFactory: QuickBuildSessionManager.WatcherFactory = + QuickBuildSessionManager.WatcherFactory { _, _, _, _ -> + error( + "not used by these seams", + ) + }, + ) = LiveSessionFactory( + daemon = daemon, + deploy = deploy, + scratch = QuickBuildScratch(FakePaths(projectRoot).projectScratchRoot), + launcher = + ProxyAppLauncher { packageName, activityClass -> + launchCalls += packageName to activityClass + true + }, + metrics = QuickBuildMetricsSink.Noop, + nowMillis = { 1000L }, + executorFactory = null, + watcherFactory = watcherFactory, + scope = CoroutineScope(StandardTestDispatcher()), + onOrchestratorEvent = {}, + assetsLiveReloadable = true, + ) + + /** A watcher that observes nothing; [create]'s retention seam never starts it. */ + private object NoopWatcher : ProjectWatcher { + override fun start(onBatch: (ChangedFiles.Known) -> Unit) = Unit + + override fun stop() = Unit + } + + private fun proxyApp( + schema: Int, + components: List = emptyList(), + annotationProcessors: List = emptyList(), + ) = ProxyAppInfo( + proxyAppPackage = "com.example.quickbuild", + entryActivity = "com.example.MainActivity", + apk = File(projectRoot, "proxy-app.apk"), + classpath = emptyList(), + proxyClassesDir = null, + transformedManifest = null, + schema = schema, + components = components, + annotationProcessors = annotationProcessors, + ) + + private fun layout() = QuickBuildProjectLayout(projectRoot) + + private suspend fun executeCodeBuild(proxyApp: ProxyAppInfo): BuildOutcome { + // A non-empty recompiled set, so the deploy policy actually decides. + daemon.compileReply = + DaemonReply.Ok( + CompileOutput(File("/fake/classes"), changedClassFiles = listOf("com/example/Foo.class")), + ) + val executor = factory().executorFor(proxyApp, layout(), GenerationTracker(MemoryGenerationStore())) + return executor.execute( + BuildRequest( + buildId = 1, + changes = ChangedFiles.Known(setOf(sourceFile)), + route = BuildRoute.CodeOnly, + // A tap: these tests read the launcher target off the recovery launch, and + // only a tap is allowed to make one. + userInitiated = true, + ), + ) + } + + @Test + fun `executorFor propagates componentInfoAvailable - a pre-v2 baseline refuses code deploys`() = + runTest { + val outcome = executeCodeBuild(proxyApp(schema = 0)) + assertThat(outcome).isInstanceOf(BuildOutcome.RequiresProxyAppRebuild::class.java) + assertThat((outcome as BuildOutcome.RequiresProxyAppRebuild).detail) + .contains("predates component metadata") + } + + @Test + fun `executorFor propagates componentInfoAvailable - a v2 baseline deploys the same change`() = + runTest { + val outcome = executeCodeBuild(proxyApp(schema = 2)) + assertThat(outcome).isInstanceOf(BuildOutcome.Success::class.java) + } + + @Test + fun `the session's retainedPayloads store reads what its own executor's deploys retain`() = + runTest { + // S8 agreement pin, reader side: create() wires the session's RetainedPayloadStore + // and the executor's internal retention from two independent derivations of the + // work dir. If they diverge, the manager's reconnect re-send looks where nothing + // is ever written and every reconnect pays the forced rebuild S8 removed. + daemon.compileReply = + DaemonReply.Ok( + CompileOutput(File("/fake/classes"), changedClassFiles = listOf("com/example/Foo.class")), + ) + daemon.dexReply = + DaemonReply.Ok( + DexOutput( + File(projectRoot, "built/classes.dex").apply { + parentFile!!.mkdirs() + writeText("dex-bytes") + }, + ), + ) + val session = + factory(watcherFactory = { _, _, _, _ -> NoopWatcher }).create( + ProvisionOutcome.Success( + proxyApp = proxyApp(schema = 2), + proxyAppUid = 10123, + layout = layout(), + ), + GenerationTracker(MemoryGenerationStore()), + ) + + val outcome = + session.executor.execute( + BuildRequest( + buildId = 1, + changes = ChangedFiles.Known(setOf(sourceFile)), + route = BuildRoute.CodeOnly, + userInitiated = true, + ), + ) + + assertThat(outcome).isInstanceOf(BuildOutcome.Success::class.java) + val retained = session.retainedPayloads.load() + assertThat(retained).isNotNull() + assertThat(retained!!.generation).isEqualTo((outcome as BuildOutcome.Success).generation) + assertThat(retained.dexFile!!.readText()).isEqualTo("dex-bytes") + } + + @Test + fun `launcher activity resolves the MAIN-LAUNCHER activity's proxyClass`() = + runTest { + // NotConnected makes the deploy recovery relaunch, which observably carries + // the launcher-activity target the factory resolved. + deploy.result = DeployResult.NotConnected + executeCodeBuild( + proxyApp( + schema = 2, + components = + listOf( + ComponentInfo( + ComponentKind.ACTIVITY, + "com.example.SettingsActivity", + proxyClass = "com.example.quickbuild.Proxy1Activity", + ), + ComponentInfo( + ComponentKind.ACTIVITY, + "com.example.MainActivity", + proxyClass = "com.example.quickbuild.Proxy0Activity", + launcher = true, + ), + ), + ), + ) + assertThat(launchCalls.single()) + .isEqualTo("com.example.quickbuild" to "com.example.quickbuild.Proxy0Activity") + } + + @Test + fun `launcher activity is null when no activity carries the launcher flag`() = + runTest { + deploy.result = DeployResult.NotConnected + executeCodeBuild( + proxyApp( + schema = 2, + components = + listOf( + ComponentInfo( + ComponentKind.ACTIVITY, + "com.example.SettingsActivity", + proxyClass = "com.example.quickbuild.Proxy1Activity", + ), + ), + ), + ) + assertThat(launchCalls.single()).isEqualTo("com.example.quickbuild" to null) + } + + @Test + fun `a project with no annotation processors gets Inactive annotation impact`() { + val impact = factory().annotationImpactFor(proxyApp(schema = 2), layout()) + assertThat(impact).isEqualTo(AnnotationImpact.Inactive) + } + + @Test + fun `a project with annotation processors gets an active analyzer`() { + val impact = + factory().annotationImpactFor( + proxyApp(schema = 2, annotationProcessors = listOf("androidx.room:room-compiler:2.6.1")), + layout(), + ) + assertThat(impact.active).isTrue() + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouterEdgeTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouterEdgeTest.kt new file mode 100644 index 0000000000..597bcd41f6 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouterEdgeTest.kt @@ -0,0 +1,53 @@ +package org.appdevforall.cotg.quickbuild.service.session + +import com.google.common.truth.Truth.assertThat +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.reload.OrchestratorEvent +import org.appdevforall.cotg.quickbuild.domain.session.SessionEvent +import org.appdevforall.cotg.quickbuild.domain.session.SessionFailure +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink +import org.junit.jupiter.api.Test + +/** + * The failure-outcome -> [SessionFailure] mapping arms [OrchestratorEventRouterTest] + * leaves untouched: a deploy failure and an infrastructure failure with the daemon + * still alive must both surface as a BuildFailed with the outcome's own message. + */ +class OrchestratorEventRouterEdgeTest { + private fun route(event: OrchestratorEvent) = + OrchestratorEventRouter(QuickBuildMetricsSink.Noop).route(event, lastDeployedGeneration = -1L, connectedGeneration = null) + + @Test + fun `a deploy failure surfaces as BuildFailed carrying the deploy message`() { + val routing = + route( + OrchestratorEvent.BuildFailed( + buildId = 1, + outcome = BuildOutcome.DeployFailure("proxy app not connected"), + route = BuildRoute.CodeOnly, + ), + ) + + assertThat(routing.sessionEvents) + .containsExactly( + SessionEvent.BuildFailed(SessionFailure.DeployError("proxy app not connected")), + ) + assertThat(routing.newLastDeployedGeneration).isNull() + } + + @Test + fun `an infrastructure failure with a live daemon is a plain BuildFailed, not DaemonDied`() { + val routing = + route( + OrchestratorEvent.BuildFailed( + buildId = 1, + outcome = BuildOutcome.InfrastructureFailure("aapt2 crashed", daemonDied = false), + route = BuildRoute.ResourcesOnly, + ), + ) + + assertThat(routing.sessionEvents) + .containsExactly(SessionEvent.BuildFailed(SessionFailure.DeployError("aapt2 crashed"))) + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouterTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouterTest.kt new file mode 100644 index 0000000000..d0992aa422 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouterTest.kt @@ -0,0 +1,184 @@ +package org.appdevforall.cotg.quickbuild.service.session + +import com.google.common.truth.Truth.assertThat +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.reload.OrchestratorEvent +import org.appdevforall.cotg.quickbuild.domain.session.SessionEvent +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink +import org.appdevforall.cotg.quickbuild.service.telemetry.report +import org.junit.jupiter.api.Test + +/** + * Seam tests for the orchestrator-fact -> session-event translation, directly + * against [OrchestratorEventRouter] (the manager's tests drive the same paths + * end-to-end; these pin the router's own branching). + */ +class OrchestratorEventRouterTest { + private fun router(metrics: QuickBuildMetricsSink = QuickBuildMetricsSink.Noop) = OrchestratorEventRouter(metrics) + + private fun route( + event: OrchestratorEvent, + lastDeployedGeneration: Long = -1L, + connectedGeneration: Long? = null, + metrics: QuickBuildMetricsSink = QuickBuildMetricsSink.Noop, + ) = router(metrics).route(event, lastDeployedGeneration, connectedGeneration) + + private fun success(generation: Long = 7L) = BuildOutcome.Success(generation = generation, durationMillis = 120L) + + @Test + fun `a warm-compile success emits WarmCompileFinished and does not advance the tally`() { + val routing = + route( + OrchestratorEvent.BuildSucceeded( + buildId = 1, + result = success(), + route = BuildRoute.WarmCompile, + ), + lastDeployedGeneration = 3L, + ) + assertThat(routing.sessionEvents).containsExactly(SessionEvent.WarmCompileFinished) + assertThat(routing.newLastDeployedGeneration).isNull() + } + + @Test + fun `a real success advances the tally to the maxed generation`() { + val routing = + route( + OrchestratorEvent.BuildSucceeded( + buildId = 1, + result = success(generation = 7L), + route = BuildRoute.CodeOnly, + userInitiated = true, + ), + lastDeployedGeneration = 3L, + ) + assertThat(routing.newLastDeployedGeneration).isEqualTo(7L) + assertThat(routing.sessionEvents) + .containsExactly( + SessionEvent.BuildSucceeded(7L, 120L, restarted = false, userInitiated = true), + ) + } + + @Test + fun `a warm-compile failure emits WarmCompileFinished and no BuildFailed`() { + val routing = + route( + OrchestratorEvent.BuildFailed( + buildId = 1, + outcome = BuildOutcome.InfrastructureFailure("compiler broke"), + route = BuildRoute.WarmCompile, + ), + ) + assertThat(routing.sessionEvents).containsExactly(SessionEvent.WarmCompileFinished) + } + + @Test + fun `a warm-compile failure with a dead daemon emits DaemonDied, not WarmCompileFinished`() { + val routing = + route( + OrchestratorEvent.BuildFailed( + buildId = 1, + outcome = BuildOutcome.InfrastructureFailure("daemon gone", daemonDied = true), + route = BuildRoute.WarmCompile, + ), + ) + assertThat(routing.sessionEvents).containsExactly(SessionEvent.DaemonDied) + } + + @Test + fun `RequiresProxyAppRebuild routes to an invalidation and books the invalidation metric`() { + var invalidations = 0 + val metrics = + object : QuickBuildMetricsSink by QuickBuildMetricsSink.Noop { + override fun onInvalidation(reason: InvalidationReason) { + invalidations++ + } + } + val routing = + route( + OrchestratorEvent.BuildFailed( + buildId = 1, + outcome = + BuildOutcome.RequiresProxyAppRebuild( + InvalidationReason.MANIFEST_CHANGED, + "manifest edit", + ), + route = BuildRoute.CodeOnly, + ), + metrics = metrics, + ) + assertThat(routing.sessionEvents) + .containsExactly(SessionEvent.InvalidationDetected(InvalidationReason.MANIFEST_CHANGED)) + assertThat(invalidations).isEqualTo(1) + } + + @Test + fun `notifyBuildingAt prefers the session tally over the connected target's self-report`() { + val routing = + route( + OrchestratorEvent.BuildStarted(1, BuildRoute.CodeOnly, ChangedFiles.Unknown), + lastDeployedGeneration = 9L, + connectedGeneration = 4L, + ) + assertThat(routing.notifyBuildingAt).isEqualTo(9L) + } + + @Test + fun `notifyBuildingAt falls back to the connected target only before the first deploy`() { + val routing = + route( + OrchestratorEvent.BuildStarted(1, BuildRoute.CodeOnly, ChangedFiles.Unknown), + lastDeployedGeneration = -1L, + connectedGeneration = 4L, + ) + assertThat(routing.notifyBuildingAt).isEqualTo(4L) + } + + @Test + fun `notifyBuildingAt is null when there is no tally and no connection`() { + val routing = + route( + OrchestratorEvent.BuildStarted(1, BuildRoute.CodeOnly, ChangedFiles.Unknown), + lastDeployedGeneration = -1L, + connectedGeneration = null, + ) + assertThat(routing.notifyBuildingAt).isNull() + } + + @Test + fun `a warm-compile start emits WarmCompileStarted and notifies nobody`() { + val routing = + route( + OrchestratorEvent.BuildStarted(1, BuildRoute.WarmCompile, ChangedFiles.Unknown), + lastDeployedGeneration = 9L, + connectedGeneration = 4L, + ) + assertThat(routing.sessionEvents).containsExactly(SessionEvent.WarmCompileStarted) + assertThat(routing.notifyBuildingAt).isNull() + } + + @Test + fun `a throwing metrics sink does not stop the routing`() { + val metrics = + object : QuickBuildMetricsSink by QuickBuildMetricsSink.Noop { + override fun onBuildFinished( + buildId: Long, + outcome: BuildOutcome, + ): Unit = throw IllegalStateException("sink broke") + } + val routing = + route( + OrchestratorEvent.BuildSucceeded( + buildId = 1, + result = success(), + route = BuildRoute.CodeOnly, + ), + metrics = metrics, + ) + assertThat(routing.sessionEvents).hasSize(1) + assertThat(routing.newLastDeployedGeneration).isEqualTo(7L) + } +} diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManagerTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManagerTest.kt new file mode 100644 index 0000000000..265dc31728 --- /dev/null +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManagerTest.kt @@ -0,0 +1,4874 @@ +package org.appdevforall.cotg.quickbuild.service.session + +import android.content.ComponentCallbacks2 +import com.google.common.truth.Truth.assertThat +import com.google.gson.JsonParser +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import org.appdevforall.cotg.quickbuild.data.DaemonReply +import org.appdevforall.cotg.quickbuild.data.ProjectWatcher +import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo +import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout +import org.appdevforall.cotg.quickbuild.data.QuickBuildScratch +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.reload.BuildRequest +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentInfo +import org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind +import org.appdevforall.cotg.quickbuild.domain.reload.LiveReloadExecutor +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildNotice +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildSessionState +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildStatus +import org.appdevforall.cotg.quickbuild.domain.session.SessionFailure +import org.appdevforall.cotg.quickbuild.domain.session.SessionReducer +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink +import org.appdevforall.cotg.quickbuild.domain.watch.WatchFilter +import org.appdevforall.cotg.quickbuild.service.FakeDaemon +import org.appdevforall.cotg.quickbuild.service.FakeDeploy +import org.appdevforall.cotg.quickbuild.service.FakePaths +import org.appdevforall.cotg.quickbuild.service.FakeQuickBuildHistoryStore +import org.appdevforall.cotg.quickbuild.service.MemoryGenerationStore +import org.appdevforall.cotg.quickbuild.service.deploy.ConnectedTarget +import org.appdevforall.cotg.quickbuild.service.deploy.DeployResult +import org.appdevforall.cotg.quickbuild.service.deploy.ProxyAppConnections +import org.appdevforall.cotg.quickbuild.service.deploy.RetainedPayloadStore +import org.appdevforall.cotg.quickbuild.service.deploy.TargetReport +import org.appdevforall.cotg.quickbuild.service.provision.ProvisionOutcome +import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppLauncher +import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppRebuildOutcome +import org.appdevforall.cotg.quickbuild.service.provision.QuickBuildProvisioner +import org.appdevforall.cotg.quickbuild.service.telemetry.report +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class QuickBuildSessionManagerTest { + @TempDir lateinit var projectRoot: File + + private val daemon = FakeDaemon() + private val deploy = + FakeDeploy().apply { + // The rebaseline relaunch awaits the relaunched app's reconnect; model an app + // that comes back at the baseline stamp, so every successful rebaseline in these + // tests books exactly ONE launch (no swallowed-start retry). + reconnectGeneration = { 0L } + } + private val connections = ProxyAppConnections() + private val store = MemoryGenerationStore() + private val historyStore = FakeQuickBuildHistoryStore() + private val userMessages = mutableListOf() + + /** Requests seen by the scripted executor, with per-request scripted outcomes. */ + private val executed = mutableListOf() + + /** + * Background warm-compile builds ([BuildRoute.WarmCompile]) recorded separately: they are a + * post-provisioning warm-up, not user work, so keeping them out of [executed] + * preserves every "the user's save produced exactly these builds" assertion. + */ + private val warmCompiles = mutableListOf() + + /** ProxyAppInfo of every executor the manager built (provision + each proxy app rebuild). */ + private val factoryProxyApps = mutableListOf() + + /** Flat trace of metrics-sink calls, e.g. "started:CodeOnly:1", "proxyAppRebuild:true". */ + private val metricsEvents = mutableListOf() + private var metricsThrow = false + + private val recordingMetrics = + object : QuickBuildMetricsSink { + override fun onSessionStarted() { + record { "session:started" } + } + + override fun onBuildStarted( + buildId: Long, + route: BuildRoute, + changes: ChangedFiles, + ) { + record { + val count = (changes as? ChangedFiles.Known)?.files?.size + "started:${route.javaClass.simpleName}:$count" + } + } + + override fun onBuildFinished( + buildId: Long, + outcome: BuildOutcome, + ) { + record { "finished:${outcome.javaClass.simpleName}" } + } + + override fun onInvalidation(reason: InvalidationReason) { + record { "invalidated:$reason" } + } + + override fun onProxyAppRebuild( + isSuccess: Boolean, + durationMillis: Long, + relaunchOk: Boolean, + toRunningMillis: Long?, + ) { + record { "proxyAppRebuild:$isSuccess" } + } + + private fun record(event: () -> String) { + if (metricsThrow) error("metrics sink boom") + metricsEvents += event() + } + } + private val scriptedOutcomes = ArrayDeque() + + /** Scripted outcomes for WARM-COMPILE builds only; empty = every warm compile succeeds unmoved. */ + private val warmCompileOutcomes = ArrayDeque() + private var provisionCount = 0 + private var proxyAppRebuildCount = 0 + private var prebuildCount = 0 + private var provisionOutcome: (() -> ProvisionOutcome)? = null + private var proxyAppRebuildOutcome: () -> ProxyAppRebuildOutcome = { defaultProxyAppRebuildSuccess() } + private var prebuildGate: kotlinx.coroutines.CompletableDeferred? = null + private var prebuildError: Throwable? = null + private var provisionGate: kotlinx.coroutines.CompletableDeferred? = null + private var provisionSurvivesCancel = false + private var proxyAppRebuildGate: kotlinx.coroutines.CompletableDeferred? = null + + /** + * Makes a gated proxy app rebuild finish its wait even after the session teardown + * cancelled it - the Gradle build runs out of process, so a cancel cannot un-run it. + * Only the epoch guard can discard the outcome it then produces. + */ + private var proxyAppRebuildSurvivesCancel = false + + /** + * When set, every executorFactory call throws it. Stands in for the real factory's + * checkNotNull(entryActivity) during a rebuild's re-baseline (the rebuild contract + * does not guarantee it non-null). + */ + private var executorFactoryError: (() -> Throwable)? = null + + /** Set to make the scripted executor await mid-build, so a test can observe Building. */ + private var executionGate: kotlinx.coroutines.CompletableDeferred? = null + private var warmCompileGate: kotlinx.coroutines.CompletableDeferred? = null + + /** Captures the watcher the manager builds so a test can push change batches. */ + private var watcher: FakeWatcher? = null + + /** + * Every request to bring the proxy app to the foreground, as (package, launcherActivity). + * Behaviours 2/3/4 are exactly "is this list empty, and when did it grow", so it is the + * assertion surface for all three. + */ + private val launches = mutableListOf>() + + /** What the launcher answers; false stands in for a refused foreground request. */ + private var launchResult = true + + /** + * Wall-clock stand-in for tests that age the deferred foreground ask; only read when + * [createManager] is given `nowMillis = { fakeNowMillis }`. + */ + private var fakeNowMillis = 0L + + /** How many times a stop reached the real Gradle proxy-app-build cancellation. */ + private var proxyAppBuildCancelCount = 0 + + /** What the Gradle cancellation answers; false means the build had already finished. */ + private var proxyAppBuildCancelResult = true + + /** + * Stands in for [org.appdevforall.cotg.quickbuild.data.AndroidProjectWatcher]: mirrors its two observable behaviours - + * it only forwards after [start] (a change before a live session is dropped), and it + * applies the same [WatchFilter] so irrelevant paths (build intermediates) are ignored. + */ + private class FakeWatcher( + private val filter: WatchFilter, + ) : ProjectWatcher { + private var onBatch: ((ChangedFiles.Known) -> Unit)? = null + + /** Survives [stop]; see [emitRacingStop]. */ + private var lastOnBatch: ((ChangedFiles.Known) -> Unit)? = null + + override fun start(onBatch: (ChangedFiles.Known) -> Unit) { + this.onBatch = onBatch + this.lastOnBatch = onBatch + } + + override fun stop() { + onBatch = null + } + + /** + * A batch the watcher thread was already delivering when [stop] landed: inotify + * cannot unwind a callback that is mid-flight, so the manager still sees it. + */ + fun emitRacingStop(modified: Set) { + val m = modified.filterTo(HashSet(), filter::isRelevant) + if (m.isNotEmpty()) lastOnBatch?.invoke(ChangedFiles.Known(m, emptySet())) + } + + /** Simulates a coalesced burst: modified/created paths plus deleted ones. */ + fun emit( + modified: Set, + removed: Set = emptySet(), + ) { + val m = modified.filterTo(HashSet(), filter::isRelevant) + val r = removed.filterTo(HashSet(), filter::isRelevant) + if (m.isNotEmpty() || r.isNotEmpty()) onBatch?.invoke(ChangedFiles.Known(m, r)) + } + } + + private lateinit var sourceFile: File + private lateinit var gradleFile: File + + @BeforeEach + fun setUp() { + sourceFile = + File(projectRoot, "app/src/main/java/com/example/Foo.kt").apply { + parentFile!!.mkdirs() + writeText("class Foo") + } + gradleFile = File(projectRoot, "build.gradle.kts").apply { writeText("// build") } + } + + private fun defaultProvisionOutcome(variantName: String? = null): ProvisionOutcome = + ProvisionOutcome.Success( + proxyApp = + ProxyAppInfo( + proxyAppPackage = "com.example.quickbuild", + entryActivity = "com.example.MainActivity", + apk = File(projectRoot, "proxy-app.apk"), + classpath = emptyList(), + proxyClassesDir = null, + transformedManifest = null, + ), + proxyAppUid = 10123, + layout = QuickBuildProjectLayout(projectRoot), + variantName = variantName, + ) + + private fun defaultProxyAppRebuildSuccess(): ProxyAppRebuildOutcome.Success { + val provision = defaultProvisionOutcome() as ProvisionOutcome.Success + return ProxyAppRebuildOutcome.Success(proxyApp = provision.proxyApp, layout = provision.layout) + } + + /** + * @param collectUserMessages whether to attach the shared [userMessages] collector. Pass false + * to test what a message raised with NOBODY collecting does - the queue is single-consumer, + * so the shared collector would take the message before the test's own could. + */ + private fun TestScope.createManager( + warmCompileEnabled: () -> Boolean = { true }, + scratch: QuickBuildScratch = QuickBuildScratch(FakePaths(projectRoot).projectScratchRoot), + nowMillis: () -> Long = System::currentTimeMillis, + collectUserMessages: Boolean = true, + ): QuickBuildSessionManager { + val provisioner = + object : QuickBuildProvisioner { + override suspend fun provision(): ProvisionOutcome { + provisionCount++ + provisionGate?.let { gate -> + if (provisionSurvivesCancel) { + try { + gate.await() + } catch (e: kotlinx.coroutines.CancellationException) { + // Simulates provisioning work already past the point of no + // return: the cancel does not stop it from producing an + // outcome, so only the epoch guard can discard it. + } + } else { + gate.await() + } + } + return provisionOutcome?.invoke() ?: defaultProvisionOutcome() + } + + override suspend fun rebuildProxyApp(): ProxyAppRebuildOutcome { + proxyAppRebuildCount++ + proxyAppRebuildGate?.let { gate -> + if (proxyAppRebuildSurvivesCancel) { + kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { gate.await() } + } else { + gate.await() + } + } + return proxyAppRebuildOutcome() + } + + override suspend fun prebuildProxyApp() { + prebuildCount++ + prebuildGate?.await() + prebuildError?.let { throw it } + } + + override fun cancelProxyAppBuild(): Boolean { + proxyAppBuildCancelCount++ + return proxyAppBuildCancelResult + } + } + return QuickBuildSessionManager( + daemon = daemon, + deploy = deploy, + provisioner = provisioner, + connections = connections, + paths = FakePaths(projectRoot), + historyStore = historyStore, + dispatcher = StandardTestDispatcher(testScheduler), + generationStoreFactory = { store }, + executorFactory = { proxyApp, _, tracker -> + executorFactoryError?.let { throw it() } + factoryProxyApps += proxyApp + object : LiveReloadExecutor { + override suspend fun execute(request: BuildRequest): BuildOutcome { + if (request.route is BuildRoute.WarmCompile) { + // Mirror the real executor's warm-compile contract: compile-only, + // nothing deployed, generation unmoved, scripted outcomes + // (which script USER builds) untouched. + warmCompiles += request + warmCompileGate?.await() + return warmCompileOutcomes.removeFirstOrNull() + ?: BuildOutcome.Success(tracker.current, 5) + } + executed += request + executionGate?.await() + return scriptedOutcomes.removeFirstOrNull() + ?: BuildOutcome.Success(tracker.next(), 5) + } + } + }, + watcherFactory = { _, _, filter, _ -> FakeWatcher(filter).also { watcher = it } }, + metrics = recordingMetrics, + warmCompileEnabled = warmCompileEnabled, + nowMillis = nowMillis, + launcher = + ProxyAppLauncher { packageName, activityClass -> + launches += packageName to activityClass + launchResult + }, + scratch = scratch, + ).also { manager -> + // Same hazard [recordNotices] documents, and the same reason for Unconfined. + if (collectUserMessages) { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + manager.userMessages.collect { userMessages += it } + } + } + } + } + + /** + * Records the neutral notice flow for the whole test; see [QuickBuildNotice]. + * + * ONE recorder per test: [QuickBuildSessionManager.notices] is a single-consumer queue, so a + * second collector would steal notices from this one. + * + * The collector MUST run on an [UnconfinedTestDispatcher]: on a StandardTestDispatcher the + * resumed collector is a background task that [advanceUntilIdle] considers idle work - once + * nothing else is queued it returns without ever running it, so a notice that really was + * raised reads as "no notice". Unconfined resumes the collector inside the sender's own call + * stack instead. + */ + private fun TestScope.recordNotices(manager: QuickBuildSessionManager): List { + val seen = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + manager.notices.collect { seen += it } + } + return seen + } + + /** Simulate an on-device file change (from any source) landing on the watcher. */ + private fun QuickBuildSessionManager.save(vararg files: File) { + watcher?.emit(files.toSet()) + } + + /** Simulate a standalone deletion the watcher's delete path detected (poll/inotify). */ + private fun QuickBuildSessionManager.deleted(vararg files: File) { + watcher?.emit(modified = emptySet(), removed = files.toSet()) + } + + /** + * Simulate a rename/move within `src/` as the watcher observes it: the destination + * [to] arrives as a create/modify (MOVED_TO) and the source [from] as a deletion + * (MOVED_FROM), coalesced into ONE burst (see AndroidProjectWatcher's DELETE_MASK). + */ + private fun QuickBuildSessionManager.renamed( + from: File, + to: File, + ) { + watcher?.emit(modified = setOf(to), removed = setOf(from)) + } + + @Test + fun `first tap provisions and lands in Ready at the persisted generation`() = + runTest { + val manager = createManager() + + manager.onQuickBuildTapped() + advanceUntilIdle() + + assertThat(provisionCount).isEqualTo(1) + assertThat(daemon.startConfigs).hasSize(1) + assertThat(connections.expectedUid).isEqualTo(10123) + assertThat(connections.expectedPackage).isEqualTo("com.example.quickbuild") + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(manager.status.value).isEqualTo(QuickBuildStatus.UpToDate(0, null)) + } + + @Test + fun `provisioning fires exactly one background warm compile that ends back in Ready`() = + runTest { + val manager = createManager() + + manager.onQuickBuildTapped() + advanceUntilIdle() + + val warmCompile = warmCompiles.single() + assertThat(warmCompile.route).isEqualTo(BuildRoute.WarmCompile) + assertThat(warmCompile.changes).isEqualTo(ChangedFiles.Unknown) + assertThat(warmCompile.forced).isFalse() + // The warm compile deployed nothing: generation unmoved, no Deployed state lingering. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(manager.status.value).isEqualTo(QuickBuildStatus.UpToDate(0, null)) + // User-build bookkeeping untouched. + assertThat(executed).isEmpty() + } + + @Test + fun `bench seam off - provisioning lands Ready with no warm compile, and a later save still builds`() = + runTest { + val manager = createManager(warmCompileEnabled = { false }) + + manager.onQuickBuildTapped() + advanceUntilIdle() + + // No warm compile was requested; the session simply stays Ready at the base generation. + assertThat(warmCompiles).isEmpty() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + + // The seam only skips the warm-up: real user work is untouched. + manager.save(sourceFile) + advanceUntilIdle() + assertThat(executed).hasSize(1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + } + + @Test + fun `a save during the warm compile queues and builds right after it - never lost, never overlapped`() = + runTest { + val manager = createManager() + val gate = CompletableDeferred() + warmCompileGate = gate + + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(warmCompiles).hasSize(1) + assertThat(executed).isEmpty() + + manager.save(File(projectRoot, "app/src/main/java/com/example/A.kt")) + advanceUntilIdle() + // Single-flight: the save waits for the in-flight warm compile. + assertThat(executed).isEmpty() + + gate.complete(Unit) + advanceUntilIdle() + assertThat(executed).hasSize(1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + } + + // The warm compile compiles what the proxy app already runs + // and deploys nothing - it must not present as a blocking Building for its whole + // 12-50s window. + @Test + fun `the background warm compile does not present as Building - status stays up to date`() = + runTest { + val manager = createManager() + val gate = CompletableDeferred() + warmCompileGate = gate + + manager.onQuickBuildTapped() + advanceUntilIdle() + + // The warm compile is in flight (gated), yet the surface reads up to date. + assertThat(warmCompiles).hasSize(1) + assertThat(manager.state.value) + .isEqualTo(QuickBuildSessionState.Building(0, warmingCompiler = true)) + assertThat(manager.status.value).isEqualTo(QuickBuildStatus.UpToDate(0, null)) + + gate.complete(Unit) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + // A clean tap during the warm compile must not vanish - the warm compile deploys nothing, + // so nothing else would satisfy it - but the app is current, so it is answered by the + // switch alone: no forced build queues behind the warm compile. + @Test + fun `a clean tap during the warm compile switches without queueing a forced build`() = + runTest { + val manager = createManager() + val gate = CompletableDeferred() + warmCompileGate = gate + + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(warmCompiles).hasSize(1) + val launchesBefore = launches.size + + manager.onQuickBuildTapped() + advanceUntilIdle() + // Answered immediately, mid-warm-compile: the deployed app is current. + assertThat(launches).hasSize(launchesBefore + 1) + assertThat(executed).isEmpty() + + gate.complete(Unit) + advanceUntilIdle() + // And no build ran for the tap once the warm compile finished, either. + assertThat(executed).isEmpty() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(launches).hasSize(launchesBefore + 1) + } + + // A crash of the running generation during the warm compile + // window surfaces like any other proxy-app crash instead of being swallowed by the + // warm compile's silent WarmCompileFinished -> Ready path. + @Test + fun `a proxy-app crash during the warm compile surfaces as a session failure`() = + runTest { + val manager = createManager() + val gate = CompletableDeferred() + warmCompileGate = gate + + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(warmCompiles).hasSize(1) + + connections.report(TargetReport.Crashed(0, "NPE in onCreate")) + advanceUntilIdle() + // Surfaced immediately, not deferred to the end of the warm-compile window. + assertThat(manager.status.value) + .isEqualTo( + QuickBuildStatus.Failed(0, SessionFailure.ProxyAppCrash("NPE in onCreate")), + ) + + gate.complete(Unit) + advanceUntilIdle() + assertThat(manager.state.value) + .isEqualTo( + QuickBuildSessionState.Ready( + 0, + lastFailure = SessionFailure.ProxyAppCrash("NPE in onCreate"), + ), + ) + } + + // Review gap (2026-07-26 #69): the daemon dying DURING the warm compile must surface as + // Degraded and recover through the normal respawn, never end in WarmCompileFinished's + // silent "up to date" over a dead daemon. + @Test + fun `a daemon death during the warm compile degrades, respawns and re-seeds the fresh daemon`() = + runTest { + val manager = createManager() + val gate = CompletableDeferred() + warmCompileGate = gate + warmCompileOutcomes += + BuildOutcome.InfrastructureFailure("daemon connection lost", daemonDied = true) + + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(warmCompiles).hasSize(1) + + // Hold the respawn's start so the honest Degraded window is observable. + val respawnGate = CompletableDeferred() + daemon.startGate = respawnGate + gate.complete(Unit) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Degraded(0)) + + respawnGate.complete(Unit) + advanceUntilIdle() + // The fresh daemon re-warmed via a second deploy-nothing warm compile; nothing + // user-visible happened: no user build, no deploy, generation unmoved. + assertThat(daemon.startConfigs).hasSize(2) + assertThat(warmCompiles).hasSize(2) + assertThat(executed).isEmpty() + assertThat(deploy.calls).isEmpty() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `a non-compose project configures the daemon without compiler plugins`() = + runTest { + val manager = createManager() + + manager.onQuickBuildTapped() + advanceUntilIdle() + + assertThat(daemon.startConfigs.single().compilerPlugins).isEmpty() + } + + @Test + fun `a compose project configures the daemon with the staged compose plugin`() = + runTest { + provisionOutcome = { + val default = defaultProvisionOutcome() as ProvisionOutcome.Success + default.copy(proxyApp = default.proxyApp.copy(composeEnabled = true)) + } + val manager = createManager() + + manager.onQuickBuildTapped() + advanceUntilIdle() + + assertThat(daemon.startConfigs.single().compilerPlugins) + .containsExactly(FakePaths(projectRoot).composeCompilerPlugin) + } + + @Test + fun `provisioning failure surfaces the error and returns to Idle`() = + runTest { + provisionOutcome = { ProvisionOutcome.Failure(QuickBuildMessage.Literal("no build service")) } + val manager = createManager() + + manager.onQuickBuildTapped() + advanceUntilIdle() + + // The failed start parks Idle with the flag, so the bolt keeps the error tone (Q8). + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle(lastStartFailed = true)) + assertThat(userMessages).containsExactly(QuickBuildMessage.Literal("no build service")) + } + + @Test + fun `a save after a failed start clears the error tone and starts nothing`() = + runTest { + provisionOutcome = { ProvisionOutcome.Failure(QuickBuildMessage.Literal("no build service")) } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(manager.status.value).isEqualTo(QuickBuildStatus.Hidden(lastStartFailed = true)) + val provisionsAfterFailure = provisionCount + + manager.onFileSaved() + advanceUntilIdle() + + // The tone is cleared, and the save did NOT retry the start - a retry stays a tap. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(manager.status.value).isEqualTo(QuickBuildStatus.Hidden()) + assertThat(provisionCount).isEqualTo(provisionsAfterFailure) + assertThat(daemon.startConfigs).isEmpty() + } + + @Test + fun `a tap after a failed start provisions again`() = + runTest { + var failFirst = true + provisionOutcome = { + if (failFirst) { + failFirst = false + ProvisionOutcome.Failure(QuickBuildMessage.Literal("no build service")) + } else { + defaultProvisionOutcome() + } + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle(lastStartFailed = true)) + + manager.onQuickBuildTapped() + advanceUntilIdle() + + // Ordinary progression: the retry provisioned and the session is live, tone READY. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + // ADFA-4930: intermediates live on app-private storage, keyed per project, guarded + // by a free-space floor, removed on teardown, swept at manager start. + + @Test + fun `a full private volume fails fast with the disk message - before the proxy app build`() = + runTest { + val scratchRoot = FakePaths(projectRoot).projectScratchRoot + val manager = + createManager(scratch = QuickBuildScratch(scratchRoot, minFreeBytes = Long.MAX_VALUE)) + + manager.onQuickBuildTapped() + advanceUntilIdle() + + // Failed BEFORE the expensive Gradle proxy app build and before any daemon spawn. + assertThat(provisionCount).isEqualTo(0) + assertThat(daemon.startConfigs).isEmpty() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle(lastStartFailed = true)) + assertThat(userMessages.single()) + .isInstanceOf(QuickBuildMessage.NotEnoughStorage::class.java) + } + + @Test + fun `the daemon out dir lands under the private scratch root, not the project`() = + runTest { + val manager = createManager() + + manager.onQuickBuildTapped() + advanceUntilIdle() + + val scratchRoot = FakePaths(projectRoot).projectScratchRoot + val outDir = daemon.startConfigs.single().outDir + assertThat(outDir.path).startsWith(scratchRoot.path) + assertThat(outDir.path).doesNotContain(".androidide") + // The tree provisioning prepared actually exists, on the private side. + assertThat(QuickBuildScratch(scratchRoot).treeFor(projectRoot).isDirectory).isTrue() + } + + @Test + fun `session teardown removes the project's scratch tree`() = + runTest { + val manager = createManager() + val tree = QuickBuildScratch(FakePaths(projectRoot).projectScratchRoot).treeFor(projectRoot) + + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(tree.isDirectory).isTrue() + + manager.restartSession() + advanceUntilIdle() + + assertThat(tree.exists()).isFalse() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + } + + @Test + fun `manager start sweeps a dead session's scratch tree before anything is live`() = + runTest { + val scratchRoot = FakePaths(projectRoot).projectScratchRoot + val stale = + File(scratchRoot, "dead-project-0123456789abcdef").apply { + File(this, "out").mkdirs() + } + + val manager = createManager() + advanceUntilIdle() + assertThat(stale.exists()).isFalse() + + // The sweep is strictly ordered before any tap: a session provisioned after + // it keeps its (new) tree. + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(QuickBuildScratch(scratchRoot).treeFor(projectRoot).isDirectory).isTrue() + } + + @Test + fun `a relevant save flows through the orchestrator to a deploy`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(sourceFile) + advanceUntilIdle() + + val request = executed.single() + assertThat(request.route).isEqualTo(BuildRoute.CodeOnly) + assertThat((request.changes as ChangedFiles.Known).files).containsExactly(sourceFile) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + assertThat(manager.status.value).isEqualTo(QuickBuildStatus.UpToDate(1, 5)) + } + + @Test + fun `a build start before any deploy this session tells the proxy app its own connect-time generation`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + connections.onConnected(connectedAt(0)) + advanceUntilIdle() + + manager.save(sourceFile) + advanceUntilIdle() + + val building = + deploy.statusCalls.single { + JsonParser + .parseString(it) + .asJsonObject + .get("kind") + .asString == "building" + } + assertThat( + JsonParser + .parseString(building) + .asJsonObject + .get("runningGeneration") + .asString, + ).isEqualTo("0") + } + + @Test + fun `a build start after a deploy uses the session's own tally, not a stale connect-time value`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + // First build: the session already knows the provisioned baseline generation + // (adopted from the provision's stamp; 0 for this unstamped fake), so even with + // no proxy app connected the "building" push names it. + manager.save(sourceFile) + advanceUntilIdle() + assertThat(executed).hasSize(1) + val runningGenerations = + deploy.statusCalls + .map { JsonParser.parseString(it).asJsonObject } + .filter { it.get("kind").asString == "building" } + .map { it.get("runningGeneration").asString } + assertThat(runningGenerations).containsExactly("0") + + // Second build: the session's own tally (gen 1, from the first build) is now + // authoritative, even though no reconnect ever refreshed a connected target. + manager.save(sourceFile) + advanceUntilIdle() + + val building = + deploy.statusCalls + .map { JsonParser.parseString(it).asJsonObject } + .last { it.get("kind").asString == "building" } + assertThat(building.get("runningGeneration").asString).isEqualTo("1") + } + + @Test + fun `an irrelevant save (build intermediates) triggers nothing`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + val outside = + File(projectRoot, "app/build/generated/Gen.kt").apply { + parentFile!!.mkdirs() + writeText("class Gen") + } + manager.save(outside) + advanceUntilIdle() + + assertThat(executed).isEmpty() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `a vanished external-tool temp file is dropped without poisoning the batch to a proxy app rebuild`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // Simulates `sed -i` rewriting sourceFile: a sibling temp file with no + // dot-prefix or recognizable suffix (WatchFilter can't name-filter it) is + // created and then renamed away before the batch settles, so it must not + // exist on disk by the time onWatcherBatch classifies the batch. + val vanishedTemp = File(projectRoot, "app/src/main/java/com/example/sedAbC123") + sourceFile.writeText("class Foo { fun bar() {} }") + + manager.save(vanishedTemp, sourceFile) + advanceUntilIdle() + + assertThat(proxyAppRebuildCount).isEqualTo(0) + val request = executed.single() + assertThat(request.route).isEqualTo(BuildRoute.CodeOnly) + assertThat((request.changes as ChangedFiles.Known).files).containsExactly(sourceFile) + } + + @Test + fun `a modify event whose target has since vanished is reclassified as a removal`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // A modify/move event arrives for a tracked .kt that is gone by batch-settle + // time (a git checkout MOVED_TO whose target was then dropped). It has a + // recognized shape, so it is NOT dropped as noise; it is routed as a removal + // (removed set), not compiled as a now-absent source. + assertThat(sourceFile.delete()).isTrue() + + manager.save(sourceFile) + advanceUntilIdle() + + val request = executed.single() + assertThat(request.route).isEqualTo(BuildRoute.CodeOnly) + val changes = request.changes as ChangedFiles.Known + assertThat(changes.files).isEmpty() + assertThat(changes.removed).containsExactly(sourceFile) + } + + @Test + fun `a standalone deletion of a tracked kt file routes CodeOnly through the removed set`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // The Bug-12 gap: a `git pull`/branch-switch/`rm` deletes a tracked source with + // NO accompanying create/modify, so it only reaches the pipeline via the + // watcher's removed channel. It must fire an incremental CodeOnly build (its + // outputs dropped + dependents recompiled), never linger until an unrelated edit. + assertThat(sourceFile.delete()).isTrue() + + manager.deleted(sourceFile) + advanceUntilIdle() + + assertThat(proxyAppRebuildCount).isEqualTo(0) + val request = executed.single() + assertThat(request.route).isEqualTo(BuildRoute.CodeOnly) + val changes = request.changes as ChangedFiles.Known + assertThat(changes.files).isEmpty() + assertThat(changes.removed).containsExactly(sourceFile) + } + + @Test + fun `a deletion with no recognized shape is dropped as noise`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // The delete detector can fire for an external tool's sibling temp + // (`sedXXXXXX`, a `patch` dropping) it saw created-then-removed. With no + // recognized project-file shape it is pure noise - dropped, no build. + val vanishedTemp = File(projectRoot, "app/src/main/java/com/example/sedAbC123") + + manager.deleted(vanishedTemp) + advanceUntilIdle() + + assertThat(executed).isEmpty() + assertThat(proxyAppRebuildCount).isEqualTo(0) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `deleting a gradle file routes to a proxy app rebuild`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // A removed build.gradle is a baseline-invalidating change (like a modified + // one): it must force the honest full Gradle proxy app rebuild, not a quick build. + manager.deleted(gradleFile) + advanceUntilIdle() + + assertThat(proxyAppRebuildCount).isEqualTo(1) + assertThat(executed).isEmpty() + } + + @Test + fun `a surviving unclassifiable file under src still forces the honest Gradle fallback`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // A real (not vanished) java-resource the live reload path can't package - existing + // on disk at batch-settle time must not exempt a genuinely unsupported file + // from the honest fallback (no over-correction from the vanished-file drop). + val unsupported = + File(projectRoot, "app/src/main/resources/config.properties").apply { + parentFile!!.mkdirs() + writeText("k=v") + } + + manager.save(unsupported) + advanceUntilIdle() + + assertThat(proxyAppRebuildCount).isEqualTo(1) + assertThat(executed).isEmpty() + } + + /** A file in a source set the app variant does not include, created on disk like a real save. */ + private fun sourceIn( + sourceSet: String, + name: String = "FooTest.kt", + ): File = + File(projectRoot, "app/src/$sourceSet/java/com/example/$name").apply { + parentFile!!.mkdirs() + writeText("class Foo") + } + + @Test + fun `a test source save runs no build at all, and explains itself exactly once`() = + runTest { + val manager = createManager() + val notices = recordNotices(manager) + manager.onQuickBuildTapped() + advanceUntilIdle() + + // Nothing under src/test is in the variant Quick Build deploys, so neither a quick + // build nor the honest Gradle fallback can carry it. A full rebuild here would cost + // the user ~97 s to produce an app that cannot differ. + val unitTest = sourceIn("test") + manager.save(unitTest) + advanceUntilIdle() + manager.save(unitTest) + advanceUntilIdle() + + assertThat(executed).isEmpty() + assertThat(proxyAppRebuildCount).isEqualTo(0) + // Once, not once per save: a user editing tests saves constantly, and repeating it + // would bury the notices that report something happening. + assertThat(notices).containsExactly(QuickBuildNotice.TEST_SOURCE_IGNORED) + } + + @Test + fun `an instrumentation test and a testFixtures save are ignored the same way`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(sourceIn("androidTest")) + advanceUntilIdle() + manager.save(sourceIn("testFixtures", name = "Fixtures.kt")) + advanceUntilIdle() + + assertThat(executed).isEmpty() + assertThat(proxyAppRebuildCount).isEqualTo(0) + } + + @Test + fun `a save-all writing a test beside a main source still builds the main one`() = + runTest { + val manager = createManager() + val notices = recordNotices(manager) + manager.onQuickBuildTapped() + advanceUntilIdle() + + // The shape a save-all really produces. Dropping the whole batch would strand the + // edit the user can actually see in their running app. + manager.save(sourceFile, sourceIn("test")) + advanceUntilIdle() + + val request = executed.single() + assertThat(request.route).isEqualTo(BuildRoute.CodeOnly) + assertThat((request.changes as ChangedFiles.Known).files).containsExactly(sourceFile) + assertThat(notices).containsExactly(QuickBuildNotice.TEST_SOURCE_IGNORED) + } + + @Test + fun `a debug source set still forces the honest Gradle fallback - it ships in the variant`() = + runTest { + val manager = createManager() + val notices = recordNotices(manager) + manager.onQuickBuildTapped() + advanceUntilIdle() + + // The precision half of this behaviour. src/debug IS compiled into the app the user + // runs, so ignoring it would leave the running app silently missing their edit - + // the quick path cannot compile it, which is what the full build is for. + manager.save(sourceIn("debug", name = "Debug.kt")) + advanceUntilIdle() + + assertThat(proxyAppRebuildCount).isEqualTo(1) + assertThat(executed).isEmpty() + assertThat(notices).isEmpty() + } + + @Test + fun `a deleted test file triggers no build either`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // Deletions are classified by the same path shape as modifications, and removing a + // test deploys no more than saving one. + val unitTest = sourceIn("test") + assertThat(unitTest.delete()).isTrue() + + manager.deleted(unitTest) + advanceUntilIdle() + + assertThat(executed).isEmpty() + assertThat(proxyAppRebuildCount).isEqualTo(0) + } + + @Test + fun `a plain in-place kt modify still classifies as code only`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // CoGo's own editor writes in place (truncate + write) - the surviving file + // never disappears, so the batch-settle existence check must not touch this + // path at all. + sourceFile.writeText("class Foo { fun bar() = 1 }") + manager.save(sourceFile) + advanceUntilIdle() + + val request = executed.single() + assertThat(request.route).isEqualTo(BuildRoute.CodeOnly) + assertThat((request.changes as ChangedFiles.Known).files).containsExactly(sourceFile) + } + + @Test + fun `a newly created source file routes CodeOnly through the modified set`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // A brand-new .kt appearing under src/ - a plugin IdeFileService.writeFile of a + // new file (audit rows 4, 7), a `git pull`/`checkout` CREATE (row 10), a Termux + // `cp`/`mv` into src (rows 19, 20), or a file-manager New Class (row 24). All land + // as a CREATE the watcher reports as a modified path that EXISTS at settle time. + val created = + File(projectRoot, "app/src/main/java/com/example/Bar.kt").apply { + parentFile!!.mkdirs() + writeText("class Bar") + } + + manager.save(created) + advanceUntilIdle() + + assertThat(proxyAppRebuildCount).isEqualTo(0) + val request = executed.single() + assertThat(request.route).isEqualTo(BuildRoute.CodeOnly) + val changes = request.changes as ChangedFiles.Known + assertThat(changes.files).containsExactly(created) + assertThat(changes.removed).isEmpty() + } + + @Test + fun `a rename within src carries the new file modified and the old removed in one CodeOnly build`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // A file-manager rename `Foo.kt` -> `Bar.kt` or a move between src/ dirs (audit + // rows 25, 26): MOVED_TO on the destination + MOVED_FROM on the source, coalesced + // into one burst. The new file compiles and the old one feeds the removed-sources + // slot (its stale .class dropped) - a single CodeOnly build, never a proxy app rebuild. + val renamedTo = + File(projectRoot, "app/src/main/java/com/example/Bar.kt").apply { + writeText("class Bar") + } + assertThat(sourceFile.delete()).isTrue() + + manager.renamed(from = sourceFile, to = renamedTo) + advanceUntilIdle() + + assertThat(proxyAppRebuildCount).isEqualTo(0) + val request = executed.single() + assertThat(request.route).isEqualTo(BuildRoute.CodeOnly) + val changes = request.changes as ChangedFiles.Known + assertThat(changes.files).containsExactly(renamedTo) + assertThat(changes.removed).containsExactly(sourceFile) + } + + @Test + fun `a standalone deletion of a resource routes ResourcesOnly through the removed set`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // A deleted res/ file with no accompanying edit (a `git pull` that drops a layout, + // a file-manager delete - audit row 27 for the resource case, Gap A). It reaches + // the pipeline only via the removed channel and must relink the shrunk resource + // set, never linger until an unrelated edit and never over-escalate to a rebuild. + val layout = File(projectRoot, "app/src/main/res/layout/activity_dead.xml") + + manager.deleted(layout) + advanceUntilIdle() + + assertThat(proxyAppRebuildCount).isEqualTo(0) + val request = executed.single() + assertThat(request.route).isEqualTo(BuildRoute.ResourcesOnly) + val changes = request.changes as ChangedFiles.Known + assertThat(changes.files).isEmpty() + assertThat(changes.removed).containsExactly(layout) + } + + @Test + fun `deleting the manifest routes to a proxy app rebuild`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // A deleted AndroidManifest.xml (a branch switch that drops it - audit rows 11, + // 12 for the manifest case) is a baseline-invalidating change exactly like a + // modified manifest: it must force the honest full Gradle proxy app rebuild, not a quick + // build off the removed set. + val manifest = File(projectRoot, "app/src/main/AndroidManifest.xml") + + manager.deleted(manifest) + advanceUntilIdle() + + assertThat(proxyAppRebuildCount).isEqualTo(1) + assertThat(executed).isEmpty() + } + + @Test + fun `saves before any session are ignored`() = + runTest { + val manager = createManager() + + manager.save(sourceFile) + advanceUntilIdle() + + assertThat(executed).isEmpty() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + } + + @Test + fun `a gradle file save invalidates and runs the full proxy app rebuild round trip`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(gradleFile) + advanceUntilIdle() + + assertThat(proxyAppRebuildCount).isEqualTo(1) + // Live reload path never ran for the gradle change. + assertThat(executed).isEmpty() + // Proxy app rebuild succeeded: back to Ready at the unchanged generation. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `a proxy app rebuild tears the daemon down for the Gradle build and restarts it on the new config`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(daemon.startConfigs).hasSize(1) + + manager.save(gradleFile) + advanceUntilIdle() + + // Torn down at proxy app rebuild start (the daemon's ~0.5GB must not coexist with + // the Gradle build's peak on low-RAM devices), restarted on success against + // the re-read proxy app info - and left RUNNING for the session that continues. + assertThat(daemon.shutdownCount).isEqualTo(1) + assertThat(daemon.startConfigs).hasSize(2) + assertThat(daemon.isRunning).isTrue() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + // Review gap (2026-07-26 #69): the test above reuses an identical proxy app info/layout, so + // restarting on the stale provisioning-time config would also pass it. Here the + // proxy app rebuild moves BOTH - the restarted daemon must reflect the new facts. + @Test + fun `the proxy app rebuild's daemon restart uses the re-read proxyApp and layout, not the provisioning-time config`() = + runTest { + // The gradle edit that forced the proxy app rebuild added a dependency jar and + // enabled Compose; the regenerated proxy app info/layout carry both. + val newJar = File(projectRoot, "libs/new-dep.jar") + proxyAppRebuildOutcome = { + val base = defaultProxyAppRebuildSuccess() + base.copy( + proxyApp = base.proxyApp.copy(composeEnabled = true), + layout = QuickBuildProjectLayout(projectRoot, classpath = listOf(newJar)), + ) + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(daemon.startConfigs.single().classpath).isEmpty() + assertThat(daemon.startConfigs.single().compilerPlugins).isEmpty() + + manager.save(gradleFile) + advanceUntilIdle() + + // Restarted against the NEW config - otherwise every quick build after + // the proxy app rebuild compiles on the old classpath without the Compose plugin. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(daemon.startConfigs).hasSize(2) + val restarted = daemon.startConfigs.last() + assertThat(restarted.classpath).containsExactly(newJar) + assertThat(restarted.compilerPlugins).isNotEmpty() + } + + // The proxy app rebuild calls daemon.shutdown() and can race an + // in-flight respawn. The daemonEpoch guard must discard the superseded respawn. + @Test + fun `a respawn superseded by a completed proxy app rebuild is discarded and leaves the new daemon alone`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // Daemon dies; the auto-respawn parks inside daemon.start. + val respawnGate = CompletableDeferred() + daemon.startGate = respawnGate + daemon.die(exitCode = 137) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Degraded(0)) + assertThat(daemon.startConfigs).hasSize(2) // provision + parked respawn + + // A gradle edit lands while Degraded: the proxy app rebuild tears the daemon down + // and restarts it on the new config while the respawn is STILL in flight. + manager.save(gradleFile) + advanceUntilIdle() + assertThat(proxyAppRebuildCount).isEqualTo(1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(daemon.startConfigs).hasSize(3) // + the proxy app rebuild's restart + assertThat(daemon.isRunning).isTrue() + val shutdownsBefore = daemon.shutdownCount + val warmCompilesBefore = warmCompiles.size + + // The parked respawn finally completes - AFTER the proxy app rebuild already owns a + // fresh daemon. It must discard itself: no DaemonRespawned, no orchestrator + // poke (a spurious warm compile), and no touching the proxy app rebuild's NEW daemon. + respawnGate.complete(Unit) + advanceUntilIdle() + assertThat(daemon.isRunning).isTrue() + assertThat(daemon.shutdownCount).isEqualTo(shutdownsBefore) + assertThat(warmCompiles).hasSize(warmCompilesBefore) + assertThat(executed).isEmpty() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `a respawn completing mid-rebuild stops its zombie daemon instead of racing the Gradle build`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + val respawnGate = CompletableDeferred() + daemon.startGate = respawnGate + daemon.die(exitCode = 137) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Degraded(0)) + + // The proxy app rebuild tears the daemon down, then parks inside its Gradle build. + val rebGate = CompletableDeferred() + proxyAppRebuildGate = rebGate + manager.save(gradleFile) + advanceUntilIdle() + assertThat(daemon.shutdownCount).isEqualTo(1) + assertThat(manager.state.value) + .isEqualTo( + QuickBuildSessionState.Provisioning( + rebaselineReason = InvalidationReason.GRADLE_CONFIG_CHANGED, + ), + ) + + // The parked respawn completes while the Gradle build still runs: its daemon + // must NOT coexist with the build (the shutdown above freed that memory on + // purpose) - the discarded respawn stops the zombie it just started. + respawnGate.complete(Unit) + advanceUntilIdle() + assertThat(daemon.isRunning).isFalse() + assertThat(daemon.shutdownCount).isEqualTo(2) + assertThat(manager.state.value) + .isEqualTo( + QuickBuildSessionState.Provisioning( + rebaselineReason = InvalidationReason.GRADLE_CONFIG_CHANGED, + ), + ) + + // The proxy app rebuild then finishes normally against its own fresh daemon. + rebGate.complete(Unit) + advanceUntilIdle() + assertThat(daemon.isRunning).isTrue() + assertThat(daemon.startConfigs).hasSize(3) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `a respawn superseded by a session restart is discarded and leaves the daemon down`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val warmCompilesBefore = warmCompiles.size + + val respawnGate = CompletableDeferred() + daemon.startGate = respawnGate + daemon.die(exitCode = 137) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Degraded(0)) + + // "Restart session" tears everything down while the respawn is in flight. + manager.restartSession() + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + + // The parked respawn completes into a torn-down session: it must not + // resurrect an orphan daemon, nor poke the dead session's orchestrator. + respawnGate.complete(Unit) + advanceUntilIdle() + assertThat(daemon.isRunning).isFalse() + assertThat(warmCompiles).hasSize(warmCompilesBefore) + assertThat(executed).isEmpty() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + } + + @Test + fun `a RequiresProxyAppRebuild outcome routes into the proxy app rebuild fallback`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + scriptedOutcomes += + BuildOutcome.RequiresProxyAppRebuild( + InvalidationReason.OUTDATED_BASELINE, + "baseline predates component metadata", + ) + + manager.save(sourceFile) + advanceUntilIdle() + + // The quick build ran once, refused to deploy, and the session fell back to + // the full proxy app rebuild (which absorbs the pending change) instead of failing. + assertThat(executed).hasSize(1) + assertThat(proxyAppRebuildCount).isEqualTo(1) + assertThat(metricsEvents).contains("invalidated:OUTDATED_BASELINE") + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `a restart deploy surfaces restarted in state and status`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + scriptedOutcomes += BuildOutcome.Success(1, 5, restarted = true) + + manager.save(sourceFile) + advanceUntilIdle() + + assertThat(manager.state.value) + .isEqualTo(QuickBuildSessionState.Deployed(1, 5, restarted = true)) + assertThat(manager.status.value) + .isEqualTo(QuickBuildStatus.UpToDate(1, 5, restarted = true)) + } + + @Test + fun `a deployed build reports started and finished metrics`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + // Provisioning reported the session boundary (build ids restart per session). + assertThat(metricsEvents).contains("session:started") + metricsEvents.clear() + + manager.save(sourceFile) + advanceUntilIdle() + + assertThat(metricsEvents) + .containsExactly( + "started:CodeOnly:1", + "finished:Success", + ).inOrder() + } + + @Test + fun `an invalidating save reports invalidation and proxy app rebuild metrics`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + metricsEvents.clear() + + manager.save(gradleFile) + advanceUntilIdle() + + assertThat(metricsEvents) + .containsExactly( + "invalidated:GRADLE_CONFIG_CHANGED", + "proxyAppRebuild:true", + // The proxy app rebuild re-enters Ready via ProvisioningSucceeded, which fires + // a fresh background warm compile: the full Gradle build may have moved inputs + // (or respawned the daemon), so re-seeding the IC universe afterwards is + // deliberate. The count is null, not 0: a warm compile's changed-set is + // Unknown - it compiles every source, not zero files. + "started:WarmCompile:null", + "finished:Success", + ).inOrder() + } + + @Test + fun `a throwing metrics sink never breaks the build`() = + runTest { + metricsThrow = true + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(sourceFile) + advanceUntilIdle() + + // The sink threw on every call; the build still deployed. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + } + + @Test + fun `a failed proxy app rebuild surfaces the error and parks recoverable`() = + runTest { + proxyAppRebuildOutcome = { ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal("manifest does not build")) } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(gradleFile) + advanceUntilIdle() + + // The user's build files do not build; the session is fine. Dying to Idle here is + // what made a broken .gradle.kts terminal while a broken .kt stayed recoverable. + assertThat(manager.state.value) + .isEqualTo( + QuickBuildSessionState.Invalidated( + InvalidationReason.GRADLE_CONFIG_CHANGED, + 0, + awaitingRetry = true, + ), + ) + assertThat(userMessages).contains(QuickBuildMessage.Literal("manifest does not build")) + } + + // Review gap (2026-07-26 #69): pin the failed proxy app rebuild's DAEMON state and the + // recovery - the session must stay recoverable, not linger wedged and daemon-less. + @Test + fun `saving the fix after a failed proxy app rebuild recovers the session`() = + runTest { + var failProxyAppRebuild = true + proxyAppRebuildOutcome = { + if (failProxyAppRebuild) { + ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal("manifest does not build")) + } else { + defaultProxyAppRebuildSuccess() + } + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(gradleFile) + advanceUntilIdle() + + // Parked, not torn down: the daemon stays down (it was shut down for the Gradle + // build and there is no new baseline to restart it against yet). + assertThat(manager.state.value) + .isEqualTo( + QuickBuildSessionState.Invalidated( + InvalidationReason.GRADLE_CONFIG_CHANGED, + 0, + awaitingRetry = true, + ), + ) + assertThat(daemon.isRunning).isFalse() + assertThat(daemon.startConfigs).hasSize(1) + + // Saving the fix is the recovery gesture - no tap, no leaving the editor. + failProxyAppRebuild = false + manager.save(gradleFile) + advanceUntilIdle() + assertThat(proxyAppRebuildCount).isEqualTo(2) + assertThat(daemon.isRunning).isTrue() + assertThat(daemon.startConfigs).hasSize(2) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + // The rebuild-Succeeded arm must build the new delegates BEFORE mutating + // session.proxyApp/layout: a factory throw (checkNotNull(entryActivity)) after the + // mutation escapes the session scope and crashes CoGo with the session half-updated. + // Built first, the arm can dispatch the ordinary rebuild-failure path instead. + @Test + fun `a delegate factory throw during the rebuild's re-baseline dispatches the failure path instead of escaping`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(factoryProxyApps).hasSize(1) + + executorFactoryError = { + IllegalStateException("Quick Build session started without an entry activity") + } + manager.save(gradleFile) + advanceUntilIdle() + + // Old baseline stayed intact (no second executor was ever installed) and the + // failure took the same path as any other failed rebuild: torn down clean to + // Idle with the error surfaced, never a crash or a wedged Provisioning. + assertThat(proxyAppRebuildCount).isEqualTo(1) + assertThat(factoryProxyApps).hasSize(1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle(lastStartFailed = true)) + assertThat(userMessages).contains(QuickBuildMessage.Literal("Quick Build session started without an entry activity")) + + // The next tap re-provisions from scratch - not wedged. + executorFactoryError = null + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(provisionCount).isEqualTo(2) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + // The Gradle proxy app rebuild SUCCEEDED but the daemon restart after it fails: the + // session must tear down to Idle (never park daemon-less) and a tap must re-provision. + @Test + fun `a daemon restart failure after a successful proxy app rebuild tears down to Idle and a tap re-provisions`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + + daemon.startReply = DaemonReply.Failed("daemon JVM would not start") + manager.save(gradleFile) + advanceUntilIdle() + + // The proxy app rebuild itself succeeded; only the restart failed. The failure + // surfaces and the session dies clean instead of wedging half-alive - flagged, so + // the bolt keeps the error tone (Q8). + assertThat(proxyAppRebuildCount).isEqualTo(1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle(lastStartFailed = true)) + assertThat(userMessages).contains(QuickBuildMessage.DaemonRestartFailed("daemon JVM would not start")) + assertThat(daemon.isRunning).isFalse() + + // The next tap re-provisions from scratch and works again. + daemon.startReply = DaemonReply.Ok(Unit) + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(provisionCount).isEqualTo(2) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `an unconfirmed proxy app rebuild install parks the session for retry instead of dying to Idle`() = + runTest { + // The multi-module verify's stranded-session failure: the proxy app rebuild's Gradle + // build succeeded but nobody tapped the reinstall dialog, so the installer + // timed out. The session must stay recoverable, not drop to Idle. + proxyAppRebuildOutcome = { + ProxyAppRebuildOutcome.InstallNotConfirmed(QuickBuildMessage.Literal("install was not confirmed")) + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(gradleFile) + advanceUntilIdle() + + assertThat(proxyAppRebuildCount).isEqualTo(1) + assertThat(manager.state.value) + .isEqualTo( + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + 0, + awaitingRetry = true, + ), + ) + // The user is told what happened; the outcome's message is surfaced as-is + // (the installer's ConfirmationNotGiven text already says how to recover + // for its specific case - not shown / declined / timed out). + assertThat(userMessages).contains(QuickBuildMessage.Literal("install was not confirmed")) + // Parked, not torn down: the daemon stays down (it was shut down for the + // Gradle build and there is no new baseline to restart it against yet). + assertThat(daemon.isRunning).isFalse() + assertThat(daemon.startConfigs).hasSize(1) + } + + @Test + fun `an unconfirmed reinstall shows the proxy app a return-to-CoGo banner`() = + runTest { + // The confirmed A06 finding (runs 20260810T003017Z/023304Z): the user watching + // the deployed app is the ONE person the CoGo-side signals (snackbar, Build + // Output, toolbar tone) cannot reach, so the park must tell the proxy app. + proxyAppRebuildOutcome = { + ProxyAppRebuildOutcome.InstallNotConfirmed(QuickBuildMessage.ReinstallReturnToCoGo) + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(gradleFile) + advanceUntilIdle() + + val kinds = + deploy.statusCalls.map { + JsonParser + .parseString(it) + .asJsonObject + .get("kind") + .asString + } + assertThat(kinds).contains("reinstall_pending") + } + + @Test + fun `a recovered rebuild clears the proxy app's reinstall-pending banner`() = + runTest { + // When the retry's rebuild skips the reinstall (bytes already matched - e.g. + // the deferred confirm completed while parked), the old process keeps running + // with the banner up; recovery must take it down explicitly. + var foregrounded = false + proxyAppRebuildOutcome = { + if (foregrounded) { + defaultProxyAppRebuildSuccess() + } else { + ProxyAppRebuildOutcome.InstallNotConfirmed(QuickBuildMessage.ReinstallReturnToCoGo) + } + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.save(gradleFile) + advanceUntilIdle() + + foregrounded = true + manager.onHostForegrounded() + advanceUntilIdle() + + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + val kinds = + deploy.statusCalls.map { + JsonParser + .parseString(it) + .asJsonObject + .get("kind") + .asString + } + // The park announced itself, and the recovery took the banner down; order + // matters - a clear before the park would leave the banner stuck. + assertThat(kinds).containsAtLeast("reinstall_pending", "build_ok").inOrder() + } + + @Test + fun `tapping Quick Build after an unconfirmed install retries the proxy app rebuild and recovers`() = + runTest { + var confirmed = false + proxyAppRebuildOutcome = { + if (confirmed) { + defaultProxyAppRebuildSuccess() + } else { + ProxyAppRebuildOutcome.InstallNotConfirmed(QuickBuildMessage.Literal("install was not confirmed")) + } + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.save(gradleFile) + advanceUntilIdle() + assertThat(proxyAppRebuildCount).isEqualTo(1) + + // The user "confirms this time": the retried install goes through. + confirmed = true + manager.onQuickBuildTapped() + advanceUntilIdle() + + assertThat(proxyAppRebuildCount).isEqualTo(2) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + // The daemon restarted against the retried rebuild's proxy app info. + assertThat(daemon.isRunning).isTrue() + assertThat(daemon.startConfigs).hasSize(2) + } + + @Test + fun `CoGo returning to the foreground after an unconfirmed install retries the proxy app rebuild`() = + runTest { + // The backgrounded-CoGo case (corpus run 20260728T044815Z): the reinstall + // ran with NO dialog ever shown - Android defers the PENDING_USER_ACTION + // broadcast until the app is foregrounded, and the dialog-owning subscriber + // is lifecycle-bound (registered onStart), so the deferred delivery can land + // before it re-registers. The user's return to CoGo must re-prompt on its + // own; they never saw anything to tap. + var foregrounded = false + proxyAppRebuildOutcome = { + if (foregrounded) { + defaultProxyAppRebuildSuccess() + } else { + ProxyAppRebuildOutcome.InstallNotConfirmed(QuickBuildMessage.Literal("install was not confirmed")) + } + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.save(gradleFile) + advanceUntilIdle() + assertThat(proxyAppRebuildCount).isEqualTo(1) + + // The user comes back to CoGo: the editor's onResume forwards this. + foregrounded = true + manager.onHostForegrounded() + advanceUntilIdle() + + assertThat(proxyAppRebuildCount).isEqualTo(2) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(daemon.isRunning).isTrue() + } + + @Test + fun `foreground auto-retries are bounded - a user who keeps declining is not re-prompted forever`() = + runTest { + // Without a bound, every resume re-runs a full Gradle proxy app rebuild for a + // user who keeps declining the reinstall. The auto-retry budget caps that; the + // session ends parked, where a TAP still retries. + proxyAppRebuildOutcome = { + ProxyAppRebuildOutcome.InstallNotConfirmed(QuickBuildMessage.Literal("install was not confirmed")) + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.save(gradleFile) + advanceUntilIdle() + assertThat(proxyAppRebuildCount).isEqualTo(1) + + // Each of the first MAX resumes retries (and re-parks, still unconfirmed). + repeat(SessionReducer.MAX_INSTALL_AUTO_RETRIES) { + manager.onHostForegrounded() + advanceUntilIdle() + } + assertThat(proxyAppRebuildCount).isEqualTo(1 + SessionReducer.MAX_INSTALL_AUTO_RETRIES) + + // Budget spent: further resumes run NO Gradle build; the session stays parked. + manager.onHostForegrounded() + advanceUntilIdle() + assertThat(proxyAppRebuildCount).isEqualTo(1 + SessionReducer.MAX_INSTALL_AUTO_RETRIES) + assertThat(manager.state.value) + .isEqualTo( + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + 0, + awaitingRetry = true, + installAutoRetries = SessionReducer.MAX_INSTALL_AUTO_RETRIES, + ), + ) + + // An explicit tap is fresh consent: it still retries. + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(proxyAppRebuildCount).isEqualTo(2 + SessionReducer.MAX_INSTALL_AUTO_RETRIES) + } + + @Test + fun `a retry that cannot get the Gradle slot defers instead of spending the auto-retry`() = + runTest { + // Losing the single Gradle slot is contention, not a build failure. Returning to + // CoGo after a gradle edit starts CoGo's own project sync (the same change + // invalidated the session) and the foreground retry asks for the slot ~2 s later, + // so this collision is routine. Charging it to the one bounded retry drops the + // session to Idle - a dead end instead of the install re-prompt. + var slotBusy = false + proxyAppRebuildOutcome = { + if (slotBusy) { + ProxyAppRebuildOutcome.BuildSlotBusy + } else { + ProxyAppRebuildOutcome.InstallNotConfirmed(QuickBuildMessage.ReinstallReturnToCoGo) + } + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.save(gradleFile) + advanceUntilIdle() + val parked = + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + 0, + awaitingRetry = true, + ) + assertThat(manager.state.value).isEqualTo(parked) + + slotBusy = true + manager.onHostForegrounded() + advanceUntilIdle() + + // It did attempt, and it parked straight back with the budget untouched. + assertThat(proxyAppRebuildCount).isEqualTo(2) + assertThat(manager.state.value).isEqualTo(parked) + // The message does not degrade to a build failure - and it must not re-state + // the park's "return to CoGo" guidance either: returning to CoGo is exactly + // what triggered this retry, so the deferral says what is actually happening. + assertThat(userMessages.last()).isEqualTo(QuickBuildMessage.ReinstallWaitingForGradle) + // A deferred attempt is not a proxy app rebuild outcome; nothing is booked against the + // proxy-app-rebuild success rate. + assertThat(metricsEvents.filter { it.startsWith("proxyAppRebuild:") }).hasSize(1) + + // The retry the deferral gave back still works when the slot frees up. + slotBusy = false + proxyAppRebuildOutcome = { defaultProxyAppRebuildSuccess() } + manager.onHostForegrounded() + advanceUntilIdle() + assertThat(proxyAppRebuildCount).isEqualTo(3) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `deferred retries do not lift the bound on real foreground retries`() = + runTest { + // The give-back must not turn into an unbounded retry loop: attempts that really + // run a Gradle build still cap at MAX_INSTALL_AUTO_RETRIES. + var slotBusy = true + proxyAppRebuildOutcome = { + if (slotBusy) { + ProxyAppRebuildOutcome.BuildSlotBusy + } else { + ProxyAppRebuildOutcome.InstallNotConfirmed(QuickBuildMessage.Literal("not confirmed")) + } + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + slotBusy = false + manager.save(gradleFile) + advanceUntilIdle() + assertThat(proxyAppRebuildCount).isEqualTo(1) + + // A deferred foreground retry costs nothing. + slotBusy = true + manager.onHostForegrounded() + advanceUntilIdle() + assertThat(proxyAppRebuildCount).isEqualTo(2) + + // The real ones then still bound at MAX. + slotBusy = false + repeat(SessionReducer.MAX_INSTALL_AUTO_RETRIES + 1) { + manager.onHostForegrounded() + advanceUntilIdle() + } + assertThat(proxyAppRebuildCount).isEqualTo(2 + SessionReducer.MAX_INSTALL_AUTO_RETRIES) + assertThat(manager.state.value) + .isEqualTo( + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + 0, + awaitingRetry = true, + installAutoRetries = SessionReducer.MAX_INSTALL_AUTO_RETRIES, + ), + ) + } + + @Test + fun `a first proxy app rebuild that cannot get the Gradle slot is reported, not parked`() = + runTest { + // Only a parked RETRY has somewhere to defer to. A first proxy app rebuild colliding + // with another build keeps the existing behaviour: surface it and go Idle, where + // the next tap re-provisions. + proxyAppRebuildOutcome = { ProxyAppRebuildOutcome.BuildSlotBusy } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(gradleFile) + advanceUntilIdle() + + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle(lastStartFailed = true)) + assertThat(userMessages).contains(QuickBuildMessage.RebuildFailed) + // Surfaced to the user as a failed proxy app rebuild, so it books like one - only a + // DEFERRED retry (slot busy while parked) skips the metrics sink. + assertThat(metricsEvents.filter { it.startsWith("proxyAppRebuild:") }) + .containsExactly("proxyAppRebuild:false") + } + + @Test + fun `onHostForegrounded is a no-op when the session is not parked`() = + runTest { + // Every editor onResume calls this; a live session must be untouched by it. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val before = manager.state.value + assertThat(before).isEqualTo(QuickBuildSessionState.Ready(0)) + + manager.onHostForegrounded() + advanceUntilIdle() + + assertThat(manager.state.value).isEqualTo(before) + assertThat(proxyAppRebuildCount).isEqualTo(0) + } + + @Test + fun `saves while parked for retry accumulate for the retried proxy app rebuild - no dead-daemon build`() = + runTest { + var confirmed = false + proxyAppRebuildOutcome = { + if (confirmed) { + defaultProxyAppRebuildSuccess() + } else { + ProxyAppRebuildOutcome.InstallNotConfirmed(QuickBuildMessage.Literal("not confirmed")) + } + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.save(gradleFile) + advanceUntilIdle() + + // A source save while parked must NOT start a quick build: the daemon is + // down, and the orchestrator still holds the proxy app rebuild's absorbed batch. + manager.save(sourceFile) + advanceUntilIdle() + assertThat(executed).isEmpty() + + // The retried proxy app rebuild absorbs the parked save (the file is on disk for + // its Gradle build); the session comes back Ready without a quick build. + confirmed = true + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(executed).isEmpty() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `a proxy app rebuild rebuilds the executor from the re-read proxyApp`() = + runTest { + // The proxy app rebuild regenerates setup.json; here it comes back schema v2 (e.g. + // a manifest edit added a service the new baseline proxies). + proxyAppRebuildOutcome = { + val base = defaultProxyAppRebuildSuccess() + base.copy(proxyApp = base.proxyApp.copy(schema = 2)) + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(factoryProxyApps).hasSize(1) + + manager.save(gradleFile) + advanceUntilIdle() + + // The live session's executor was rebuilt from the RE-READ proxy app info, not left + // on the provisioning-time snapshot - otherwise the deploy policy would + // keep routing on stale component facts for the rest of the session. + assertThat(factoryProxyApps).hasSize(2) + assertThat(factoryProxyApps.last().schema).isEqualTo(2) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `a stale reconnect triggers a catch-up build`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.save(sourceFile) + advanceUntilIdle() + assertThat(executed).hasSize(1) + + // A killed-and-relaunched proxy app that lost the deployed payload boots and + // reconnects at gen 0 - verifiably running code this session superseded. + connections.onConnected(connectedAt(0)) + advanceUntilIdle() + + assertThat(executed).hasSize(2) + assertThat(executed.last().forced).isTrue() + } + + @Test + fun `a reconnect at the deployed generation does not trigger a build`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.save(sourceFile) + advanceUntilIdle() + assertThat(executed).hasSize(1) + + connections.onConnected(connectedAt(1)) + advanceUntilIdle() + + assertThat(executed).hasSize(1) + } + + @Test + fun `a gen-0 reconnect after a proxy app rebuild does not trigger a catch-up build`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.save(sourceFile) + advanceUntilIdle() + manager.save(gradleFile) + advanceUntilIdle() + val buildsBefore = executed.size + + // The proxy app rebuild reinstalled a fresh baseline; its gen-0 IS current code, + // so a reconnect at 0 must not be mistaken for staleness. + connections.onConnected(connectedAt(0)) + advanceUntilIdle() + + assertThat(executed).hasSize(buildsBefore) + } + + @Test + fun `a stamped provision adopts the baseline generation, so a reconnect at the stamp is in sync`() = + runTest { + // The provisioner allocated 5 from the persistent counter and stamped it into + // the APK; the installed app boots (and reconnects) at 5, never at 0. + provisionOutcome = { + (defaultProvisionOutcome() as ProvisionOutcome.Success).copy(baselineGeneration = 5L) + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(5)) + + connections.onConnected(connectedAt(5)) + advanceUntilIdle() + // In sync by construction: no catch-up build for a freshly provisioned app. + assertThat(executed).isEmpty() + + // The session's allocator adopted the stamp, so the first deploy is strictly + // newer than the installed baseline. + manager.save(sourceFile) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(6, 5)) + } + + @Test + fun `a rebaseline's stamp becomes the deployed generation, so the post-rebaseline reconnect forces no build`() = + runTest { + // concurrency.md rule 2, the bug this exists for: before stamping, a rebaselined + // app booted 0 while the session's tally held the old epoch's number, and every + // reconnect forced a pointless catch-up build. + provisionOutcome = { + (defaultProvisionOutcome() as ProvisionOutcome.Success).copy(baselineGeneration = 1L) + } + proxyAppRebuildOutcome = { + // The rebaseline allocated the next number (3: the deploy below burned 2) + // from the same counter and stamped it into the reinstalled APK. + defaultProxyAppRebuildSuccess().copy(baselineGeneration = 3L) + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.save(sourceFile) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(2, 5)) + + manager.save(gradleFile) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(3)) + val buildsBefore = executed.size + + // The reinstalled app boots at its stamp and reconnects there: in sync, no + // catch-up build. + connections.onConnected(connectedAt(3)) + advanceUntilIdle() + assertThat(executed).hasSize(buildsBefore) + + // And the next deploy stays strictly above the stamped baseline, so the + // runtime cannot reject it as stale. + manager.save(sourceFile) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(4, 5)) + } + + @Test + fun `a below-deployed reconnect re-sends the retained payload instead of rebuilding`() = + runTest { + // concurrency.md rules 3-4: the session still holds the bytes it last deployed, + // so a proxy app that lost its persisted payload is repaired by re-sending them + // at their original generation - not by a forced blind rebuild of a module that + // did not change. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.save(sourceFile) + advanceUntilIdle() + assertThat(executed).hasSize(1) + + seedRetainedPayload(generation = 1L) + connections.onConnected(connectedAt(0)) + advanceUntilIdle() + + // The retained bytes went straight through the deploy channel at their original + // generation, and no build ran. + val resent = deploy.calls.single() + assertThat(resent.generation).isEqualTo(1L) + assertThat(resent.dexFile!!.readText()).isEqualTo("retained-dex") + assertThat(resent.metadataJson).contains("entryActivity") + assertThat(executed).hasSize(1) + } + + @Test + fun `a failed re-send falls back to the forced catch-up build`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.save(sourceFile) + advanceUntilIdle() + assertThat(executed).hasSize(1) + + seedRetainedPayload(generation = 1L) + // The relaunched app dropped its binding again before the re-send landed. + deploy.result = DeployResult.NotConnected + connections.onConnected(connectedAt(0)) + advanceUntilIdle() + + // Re-send attempted once, then the last-resort repair: a forced rebuild of + // current sources. + assertThat(deploy.calls).hasSize(1) + assertThat(executed).hasSize(2) + assertThat(executed.last().forced).isTrue() + } + + @Test + fun `retention from an older deploy is never replayed - the forced build repairs instead`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.save(sourceFile) + advanceUntilIdle() + manager.save(sourceFile) + advanceUntilIdle() + assertThat(executed).hasSize(2) + + // Retention stuck at generation 1 while the session deployed 2 (the later + // retention write failed). Replaying 1 would leave the app still behind the + // deploy tally with nothing left to notice it. + seedRetainedPayload(generation = 1L) + connections.onConnected(connectedAt(0)) + advanceUntilIdle() + + assertThat(deploy.calls).isEmpty() + assertThat(executed).hasSize(3) + assertThat(executed.last().forced).isTrue() + } + + /** + * Writes a retained last-deployed payload where the live session's store reads it, as + * the real executor would have after a confirmed deploy - the scripted executor in these + * tests deploys (and so retains) nothing. + */ + private fun seedRetainedPayload(generation: Long) { + val dex = File(projectRoot, "retained.dex").apply { writeText("retained-dex") } + RetainedPayloadStore + .forWorkDir(QuickBuildScratch(FakePaths(projectRoot).projectScratchRoot).workDirFor(projectRoot)) + .retain(generation, dex, null, null, """{"entryActivity":"com.example.MainActivity"}""") + } + + private fun connectedAt(generation: Long): ConnectedTarget = + ConnectedTarget( + target = + object : com.itsaky.androidide.quickbuild.IQuickBuildTarget { + override fun onBuildStatus(statusJson: String?) = Unit + + override fun onPayload( + generation: Long, + dexPayload: android.os.ParcelFileDescriptor?, + resourcesPayload: android.os.ParcelFileDescriptor?, + assetsPayload: android.os.ParcelFileDescriptor?, + metadataJson: String?, + ) = Unit + + override fun asBinder(): android.os.IBinder? = null + }, + packageName = "com.example.quickbuild", + runningGeneration = generation, + ) + + @Test + fun `a clean tap while Ready builds nothing - the deployed app is already current`() = + runTest { + // The F7 do-nothing tap: the old behavior forced a blind NoOp rebuild that + // recompiled a whole module to redeploy identical bytes. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val launchesBefore = launches.size + + manager.onQuickBuildTapped() + advanceUntilIdle() + + assertThat(executed).isEmpty() + assertThat(launches).hasSize(launchesBefore + 1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `compile error lands in Ready with the failure surfaced and generation unmoved`() = + runTest { + val diagnostics = + listOf( + BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "unresolved reference"), + ) + scriptedOutcomes += BuildOutcome.CompileError(diagnostics) + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(sourceFile) + advanceUntilIdle() + + val state = manager.state.value + assertThat(state).isInstanceOf(QuickBuildSessionState.Ready::class.java) + assertThat((state as QuickBuildSessionState.Ready).generation).isEqualTo(0) + assertThat(state.lastFailure) + .isEqualTo(SessionFailure.CompileError(diagnostics)) + assertThat(manager.status.value) + .isEqualTo(QuickBuildStatus.Failed(0, SessionFailure.CompileError(diagnostics))) + } + + @Test + fun `daemon death with nothing pending respawns and re-warms via a deploy-nothing warm compile`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + daemon.die(exitCode = 137) + advanceUntilIdle() + + // Respawned: configure ran twice (provision + respawn)... + assertThat(daemon.startConfigs).hasSize(2) + // ...and with nothing pending the re-warm is a WARM COMPILE (one per daemon life: + // provisioning's + the respawn's) - no user build, no deploy, the proxy app + // keeps running its current generation untouched. + assertThat(executed).isEmpty() + assertThat(warmCompiles).hasSize(2) + assertThat(warmCompiles.last().changes).isEqualTo(ChangedFiles.Unknown) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + /** + * The bench seam turns off PROVISIONING's warm-up only. A respawn's re-seed is not a + * warm-up: with work pending it is what tells the fresh daemon its incremental universe + * is gone, so gating the whole re-seed would trade a benchmark arm's tidiness for a + * build that recompiles only the changed files against a daemon holding nothing. Pinned + * so a future gate cannot land silently. + */ + @Test + fun `the daemon-respawn re-seed runs even with the warm compile bench seam off`() = + runTest { + val manager = createManager(warmCompileEnabled = { false }) + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(warmCompiles).isEmpty() + + daemon.die(exitCode = 137) + advanceUntilIdle() + + assertThat(daemon.startConfigs).hasSize(2) + // Exactly one warm compile: the respawn's, the one provisioning skipped. + assertThat(warmCompiles.single().changes).isEqualTo(ChangedFiles.Unknown) + assertThat(executed).isEmpty() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `proxy app crash reported by the host service surfaces as a session failure`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + connections.report(TargetReport.Crashed(0, "NullPointerException in onCreate")) + advanceUntilIdle() + + val state = manager.state.value + assertThat(state).isInstanceOf(QuickBuildSessionState.Ready::class.java) + assertThat((state as QuickBuildSessionState.Ready).lastFailure) + .isEqualTo(SessionFailure.ProxyAppCrash("NullPointerException in onCreate")) + } + + @Test + fun `prebuild runs the proxy app build only - no install, no daemon, back to Idle`() = + runTest { + val manager = createManager() + + manager.prebuild() + advanceUntilIdle() + + assertThat(prebuildCount).isEqualTo(1) + // Nothing provisioned: no install path, no daemon, no watcher, no session. + assertThat(provisionCount).isEqualTo(0) + assertThat(daemon.startConfigs).isEmpty() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(manager.status.value).isEqualTo(QuickBuildStatus.Hidden()) + } + + @Test + fun `tap during prebuild queues and provisions once the warm build finishes`() = + runTest { + prebuildGate = kotlinx.coroutines.CompletableDeferred() + val manager = createManager() + + manager.prebuild() + advanceUntilIdle() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // The tap does not race the warm Gradle build. + assertThat(provisionCount).isEqualTo(0) + assertThat(manager.state.value) + .isEqualTo(QuickBuildSessionState.Prebuilding(tapQueued = true)) + assertThat(manager.status.value).isEqualTo(QuickBuildStatus.Provisioning()) + + prebuildGate!!.complete(Unit) + advanceUntilIdle() + + assertThat(provisionCount).isEqualTo(1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `prebuild failure is silent and leaves the session Idle`() = + runTest { + prebuildError = RuntimeException("proxy app build failed") + val manager = createManager() + + manager.prebuild() + advanceUntilIdle() + + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + // The user never asked for the warm build; no error surfaces. + assertThat(userMessages).isEmpty() + } + + @Test + fun `prebuild while a session is live does not disturb it`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.prebuild() + advanceUntilIdle() + + assertThat(prebuildCount).isEqualTo(0) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `a sync that did not change the build variant leaves a live session alone`() = + runTest { + provisionOutcome = { defaultProvisionOutcome("demoDebug") } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.onProjectSynced("demoDebug") + advanceUntilIdle() + + // Same no-op as a bare prebuild: an ordinary sync must not cost a reprovision. + assertThat(provisionCount).isEqualTo(1) + assertThat(prebuildCount).isEqualTo(0) + assertThat(daemon.shutdownCount).isEqualTo(0) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `a sync that changed the build variant reprovisions the live session`() = + runTest { + // Applying a new Build Variants selection re-syncs the project. Left alone, the + // live session keeps hot-reloading into the OLD variant's proxy app - a different + // applicationId as soon as a flavor carries a suffix, so the user edits one app and + // watches another. + provisionOutcome = { defaultProvisionOutcome("demoDebug") } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + provisionOutcome = { defaultProvisionOutcome("fullDebug") } + manager.onProjectSynced("fullDebug") + advanceUntilIdle() + + assertThat(provisionCount).isEqualTo(2) + assertThat(daemon.shutdownCount).isEqualTo(1) + assertThat(daemon.startConfigs).hasSize(2) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `a sync that cannot name the selected variant leaves a live session alone`() = + runTest { + // The project model has no module to ask during a sync, and an unknown selection is + // not evidence of a change - tearing a healthy session down on it would make an + // ordinary sync a coin flip. + provisionOutcome = { defaultProvisionOutcome("demoDebug") } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.onProjectSynced(null) + advanceUntilIdle() + + assertThat(provisionCount).isEqualTo(1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `a sync with no live session warms the proxy app build`() = + runTest { + val manager = createManager() + + manager.onProjectSynced("demoDebug") + advanceUntilIdle() + + // Nothing to compare against, so the sync hook is exactly the eager prebuild. + assertThat(prebuildCount).isEqualTo(1) + assertThat(provisionCount).isEqualTo(0) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + } + + @Test + fun `prebuild runs even on a project that has never used Quick Build`() = + runTest { + // Skipping the warm-up until Quick Build has been tapped once on the project would + // make the FIRST tap on every new project pay the whole cold proxy app build cost + // (~97 s on an a56 for a small app). If the feature is enabled, warm it -- the flag + // is the only gate. + historyStore.setHasUsedQuickBuild(false) + val manager = createManager() + + manager.prebuild() + advanceUntilIdle() + + assertThat(prebuildCount).isEqualTo(1) + } + + @Test + fun `tapping Quick Build still records that the project used it`() = + runTest { + historyStore.setHasUsedQuickBuild(false) + val manager = createManager() + + manager.onQuickBuildTapped() + advanceUntilIdle() + + assertThat(historyStore.hasUsedQuickBuild()).isTrue() + } + + @Test + fun `the tap reaches the reducer before the history write, not after it`() = + runTest { + // The reducer must see the tap without waiting on the history write, which is a + // side effect that can be slow. prebuild() dispatches immediately, so a tap + // sequenced behind that write can be reduced after PrebuildFinished has already + // settled the session back to Idle - which is what a "dead" first press on the + // primary control looks like. + var stateAtWrite: QuickBuildSessionState? = null + val manager = createManager() + historyStore.onWrite = { stateAtWrite = manager.state.value } + + manager.onQuickBuildTapped() + advanceUntilIdle() + + assertThat(stateAtWrite).isNotNull() + assertThat(stateAtWrite).isNotEqualTo(QuickBuildSessionState.Idle()) + } + + @Test + fun `a tap still starts the session when recording history fails`() = + runTest { + // A throwing store must not kill the coroutine before the dispatch: that loses + // the tap outright - the one press the parked-session banner tells the user to + // make. + historyStore.writeError = IllegalStateException("no project open") + val manager = createManager() + + manager.onQuickBuildTapped() + advanceUntilIdle() + + assertThat(provisionCount).isEqualTo(1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `after a failed provisioning the first tap starts a session even mid-prebuild`() = + runTest { + // End to end: a proxy app rebuild retry failed, the session is Idle, + // CoGo's project sync then finishes and fires the project-open prebuild - and the + // user's FIRST tap has to start the session, not be absorbed by the warm-up. + provisionOutcome = { ProvisionOutcome.Failure(QuickBuildMessage.Literal("Proxy app rebuild failed")) } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle(lastStartFailed = true)) + + prebuildGate = kotlinx.coroutines.CompletableDeferred() + manager.prebuild() + advanceUntilIdle() + // The warm build still runs; the failed-start flag rides along uncleared (Q8). + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Prebuilding(lastStartFailed = true)) + + provisionOutcome = null + manager.onQuickBuildTapped() + advanceUntilIdle() + // Recorded on the warm-up rather than dropped: the queued tap is what turns + // PrebuildFinished into provisioning instead of a return to Idle. + assertThat(manager.state.value) + .isEqualTo(QuickBuildSessionState.Prebuilding(tapQueued = true)) + + prebuildGate!!.complete(Unit) + advanceUntilIdle() + + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(provisionCount).isEqualTo(2) + } + + @Test + fun `standard run completion refreshes the baseline - the next save recompiles everything`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.onStandardRunCompleted() + advanceUntilIdle() + + // Deferred refresh: no build behind the user's back, state unchanged. + assertThat(executed).isEmpty() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + + manager.save(sourceFile) + advanceUntilIdle() + + // The save after the hand-back recompiles from current disk, never stale. + val request = executed.single() + assertThat(request.changes).isEqualTo(ChangedFiles.Unknown) + assertThat(request.route).isEqualTo(BuildRoute.CodeAndResources) + } + + @Test + fun `standard run completion with clobbered proxy app build artifacts forces a full proxy app rebuild`() = + runTest { + provisionOutcome = { + val base = defaultProvisionOutcome() as ProvisionOutcome.Success + base.copy( + proxyApp = + base.proxyApp.copy( + // Points at nothing on disk - as after an external clean. + proxyClassesDir = File(projectRoot, "build/quickbuild/proxy-gone"), + ), + ) + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.onStandardRunCompleted() + advanceUntilIdle() + + // EXTERNAL_FULL_BUILD routed through the invalidation machinery. + assertThat(proxyAppRebuildCount).isEqualTo(1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `standard run completion with all proxy app build artifacts present refreshes the baseline incrementally`() = + runTest { + val jar = + File(projectRoot, "build/intermediates/r.jar").apply { + parentFile!!.mkdirs() + writeText("jar") + } + val proxyDir = File(projectRoot, "build/quickbuild/proxies").apply { mkdirs() } + val manifest = + File(projectRoot, "build/quickbuild/AndroidManifest.xml").apply { + writeText("") + } + provisionOutcome = { + val base = defaultProvisionOutcome() as ProvisionOutcome.Success + base.copy( + proxyApp = + base.proxyApp.copy( + classpath = listOf(jar), + proxyClassesDir = proxyDir, + transformedManifest = manifest, + ), + ) + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.onStandardRunCompleted() + advanceUntilIdle() + manager.save(sourceFile) + advanceUntilIdle() + + assertThat(proxyAppRebuildCount).isEqualTo(0) + assertThat(executed.single().changes).isEqualTo(ChangedFiles.Unknown) + } + + @Test + fun `standard run completion with a missing classpath jar forces a full proxy app rebuild`() = + runTest { + provisionOutcome = { + val base = defaultProvisionOutcome() as ProvisionOutcome.Success + base.copy( + proxyApp = + base.proxyApp.copy( + classpath = listOf(File(projectRoot, "build/intermediates/r.jar")), + ), + ) + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.onStandardRunCompleted() + advanceUntilIdle() + + assertThat(proxyAppRebuildCount).isEqualTo(1) + } + + @Test + fun `standard run completion without a session is a no-op`() = + runTest { + val manager = createManager() + + manager.onStandardRunCompleted() + advanceUntilIdle() + + assertThat(executed).isEmpty() + assertThat(proxyAppRebuildCount).isEqualTo(0) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + } + + @Test + fun `restartSession tears down a live session and a later tap re-provisions fresh`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(daemon.startConfigs).hasSize(1) + + manager.restartSession() + advanceUntilIdle() + + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(manager.status.value).isEqualTo(QuickBuildStatus.Hidden()) + assertThat(daemon.shutdownCount).isEqualTo(1) + assertThat(connections.expectedUid).isNull() + assertThat(connections.expectedPackage).isNull() + // The old watcher must not still be able to trigger a build post-restart. + manager.save(sourceFile) + advanceUntilIdle() + assertThat(executed).isEmpty() + + manager.onQuickBuildTapped() + advanceUntilIdle() + + assertThat(provisionCount).isEqualTo(2) + assertThat(daemon.startConfigs).hasSize(2) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `restartSessionAndReprovision tears down and provisions again without a second tap`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(daemon.startConfigs).hasSize(1) + + manager.restartSessionAndReprovision() + advanceUntilIdle() + + // T15: the whole point. The old session is gone AND a new one is live, with no + // second tap - resting at Idle is what read as "does restart session do anything?". + assertThat(provisionCount).isEqualTo(2) + assertThat(daemon.shutdownCount).isEqualTo(1) + assertThat(daemon.startConfigs).hasSize(2) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `restartSessionAndReprovision starts the new daemon only after the old one is down`() = + runTest { + // The teardown's daemon shutdown is asynchronous, so chaining a provision straight + // behind it would otherwise be safe only by timing - the shutdown happening to finish + // inside the new session's Gradle build. Hold the shutdown open and the ordering has + // to carry it: nothing of the new session may start meanwhile, or that in-flight + // shutdown is handed the daemon the new session just spawned. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(daemon.startConfigs).hasSize(1) + + val shutdownGate = CompletableDeferred() + daemon.shutdownGate = shutdownGate + manager.restartSessionAndReprovision() + advanceUntilIdle() + + assertThat(provisionCount).isEqualTo(1) + assertThat(daemon.startConfigs).hasSize(1) + + shutdownGate.complete(Unit) + advanceUntilIdle() + + assertThat(daemon.shutdownCount).isEqualTo(1) + assertThat(daemon.startConfigs).hasSize(2) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `restartSessionAndReprovision from idle provisions a session`() = + runTest { + val manager = createManager() + + manager.restartSessionAndReprovision() + advanceUntilIdle() + + assertThat(provisionCount).isEqualTo(1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `a restart mid-provision cancels the Gradle build, not just the coroutine awaiting it`() = + runTest { + // Cancelling the coroutine abandons the AWAIT; Gradle runs out of process behind a + // future and keeps running. It holds the device's single build slot, so the + // reprovision behind this teardown is refused SlotBusy - the user taps "Restart + // session" and gets a setup failure. Only the stop tap emitted a cancel effect, and a + // restart is not a stop tap. + provisionGate = kotlinx.coroutines.CompletableDeferred() + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(manager.state.value) + .isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) + + manager.restartSession() + advanceUntilIdle() + + assertThat(proxyAppBuildCancelCount).isEqualTo(1) + } + + @Test + fun `an idle teardown cancels nothing - there is no build of ours to stop`() = + runTest { + // The provisioner refuses a cancel that would kill the user's own Standard Run, but + // this must not lean on that: with no session work there is nothing of ours in + // flight, so the request is not made at all. + val manager = createManager() + + manager.restartSession() + advanceUntilIdle() + + assertThat(proxyAppBuildCancelCount).isEqualTo(0) + } + + @Test + fun `restart during provisioning cancels the in-flight provision - no zombie session`() = + runTest { + provisionGate = kotlinx.coroutines.CompletableDeferred() + val manager = createManager() + + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(manager.state.value) + .isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) + + manager.restartSession() + advanceUntilIdle() + provisionGate!!.complete(Unit) + advanceUntilIdle() + + // The cancelled provision never went live: no daemon, no watcher, still Idle. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(daemon.startConfigs).isEmpty() + manager.save(sourceFile) + advanceUntilIdle() + assertThat(executed).isEmpty() + + // The next tap provisions from scratch, with exactly one live watcher/daemon. + provisionGate = null + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(provisionCount).isEqualTo(2) + assertThat(daemon.startConfigs).hasSize(1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `a provision that outlives the restart is discarded by the epoch guard`() = + runTest { + provisionGate = kotlinx.coroutines.CompletableDeferred() + provisionSurvivesCancel = true + val manager = createManager() + + manager.onQuickBuildTapped() + advanceUntilIdle() + + // Restart while provisioning; the provision ignores the cancel and still + // produces a Success outcome - it must not resurrect a session behind Idle. + manager.restartSession() + advanceUntilIdle() + + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(manager.status.value).isEqualTo(QuickBuildStatus.Hidden()) + assertThat(daemon.startConfigs).isEmpty() + assertThat(connections.expectedPackage).isNull() + manager.save(sourceFile) + advanceUntilIdle() + assertThat(executed).isEmpty() + } + + @Test + fun `restart during prebuild cancels the warm wait and the next tap provisions fresh`() = + runTest { + prebuildGate = kotlinx.coroutines.CompletableDeferred() + val manager = createManager() + + manager.prebuild() + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Prebuilding()) + + manager.restartSession() + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + + manager.onQuickBuildTapped() + advanceUntilIdle() + + assertThat(provisionCount).isEqualTo(1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `provisioning failure emits on the userMessages flow`() = + runTest { + provisionOutcome = { ProvisionOutcome.Failure(QuickBuildMessage.Literal("no build service")) } + val manager = createManager() + + manager.onQuickBuildTapped() + advanceUntilIdle() + + // The shared recorder is the one collector; the queue is single-consumer, so a + // second one here would race it for the message. + assertThat(userMessages).containsExactly(QuickBuildMessage.Literal("no build service")) + } + + @Test + fun `restartSession while idle is a no-op`() = + runTest { + val manager = createManager() + + manager.restartSession() + advanceUntilIdle() + + assertThat(daemon.shutdownCount).isEqualTo(0) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + } + + @Test + fun `infrastructure failure with daemonDied routes to the Degraded flow, not BuildFailed`() = + runTest { + scriptedOutcomes += BuildOutcome.InfrastructureFailure("pipe broke", daemonDied = true) + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(sourceFile) + advanceUntilIdle() + + // DaemonDied -> Degraded -> respawn -> Ready -> Unknown re-seed build succeeds. + assertThat(daemon.startConfigs).hasSize(2) + assertThat(executed.last().changes).isEqualTo(ChangedFiles.Unknown) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + } + + @Test + fun `onTrimMemory below RUNNING_CRITICAL is a no-op`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE) + advanceUntilIdle() + manager.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) + advanceUntilIdle() + + assertThat(daemon.shutdownCount).isEqualTo(0) + assertThat(daemon.isRunning).isTrue() + } + + @Test + fun `onTrimMemory at RUNNING_CRITICAL tears down an idle daemon`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(daemon.isRunning).isTrue() + + manager.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) + advanceUntilIdle() + + assertThat(daemon.shutdownCount).isEqualTo(1) + assertThat(daemon.isRunning).isFalse() + } + + @Test + fun `onTrimMemory at UI_HIDDEN keeps the daemon warm - backgrounding is mid-loop, not pressure`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // The user switched to their running proxy app to look at the edit they just + // made; they are coming back to edit again. UI_HIDDEN is not memory pressure. + manager.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) + advanceUntilIdle() + + assertThat(daemon.shutdownCount).isEqualTo(0) + assertThat(daemon.isRunning).isTrue() + } + + @Test + fun `onTrimMemory at BACKGROUND tears down - a cached-process trim is real pressure`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) + advanceUntilIdle() + + assertThat(daemon.shutdownCount).isEqualTo(1) + assertThat(daemon.isRunning).isFalse() + } + + @Test + fun `onTrimMemory is idempotent across repeated critical signals`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) + advanceUntilIdle() + manager.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_COMPLETE) + advanceUntilIdle() + + // One real shutdown call; the second signal found the daemon already down. + assertThat(daemon.shutdownCount).isEqualTo(1) + } + + @Test + fun `onTrimMemory with no live session is a safe no-op`() = + runTest { + val manager = createManager() + + manager.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) + advanceUntilIdle() + + assertThat(daemon.shutdownCount).isEqualTo(0) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + } + + @Test + fun `onTrimMemory during a build defers the teardown until the build completes`() = + runTest { + executionGate = kotlinx.coroutines.CompletableDeferred() + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(sourceFile) + advanceUntilIdle() + assertThat(manager.state.value).isInstanceOf(QuickBuildSessionState.Building::class.java) + + manager.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) + advanceUntilIdle() + + // Must not tear down mid-compile: the build is still in flight. + assertThat(daemon.shutdownCount).isEqualTo(0) + assertThat(daemon.isRunning).isTrue() + + executionGate!!.complete(Unit) + advanceUntilIdle() + + // The deferred teardown applied the moment the build's own transition landed. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + assertThat(daemon.shutdownCount).isEqualTo(1) + assertThat(daemon.isRunning).isFalse() + } + + @Test + fun `a Quick Build after a low-memory teardown re-warms the daemon and still succeeds`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) + advanceUntilIdle() + assertThat(daemon.isRunning).isFalse() + + // The scripted executor doesn't know the daemon died; script what the REAL + // executor reports for a torn-down daemon (LiveReloadExecutorImpl.compileAndDex + // maps DaemonReply.Failed(daemonDied=true) to exactly this outcome). + scriptedOutcomes += BuildOutcome.InfrastructureFailure("daemon not running", daemonDied = true) + + manager.save(sourceFile) + advanceUntilIdle() + + // DaemonDied -> Degraded -> auto respawn -> Ready -> Unknown re-seed build + // succeeds - "slower, not broken": no user retap needed. + assertThat(daemon.startConfigs).hasSize(2) + assertThat(executed.last().changes).isEqualTo(ChangedFiles.Unknown) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + } + + // Bryan's button spec, behaviours 2-5. The governing principle: bringing the proxy app + // forward answers the USER asking. A tap asks; a save does not; a cancelled tap withdraws + // the ask. Each test below pins one of those clauses. + + @Test + fun `the first tap brings the freshly installed proxy app to the foreground`() = + runTest { + // Behaviour 2 at its coldest: nothing else in the system ever launches the proxy + // app after its install, so if the session going live did not do it the user would + // tap, wait through the whole provisioning, and be left staring at the editor. + val manager = createManager() + + manager.onQuickBuildTapped() + advanceUntilIdle() + + assertThat(launches).containsExactly("com.example.quickbuild" to null) + } + + @Test + fun `a save-triggered build never brings the proxy app forward`() = + runTest { + // Behaviour 3: the user is typing. A save is not a request to leave the editor. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val launchesAfterProvisioning = launches.size + + manager.save(sourceFile) + advanceUntilIdle() + + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + assertThat(launches).hasSize(launchesAfterProvisioning) + } + + @Test + fun `a tap landing on a save-triggered build switches when THAT build deploys, without rebuilding`() = + runTest { + // Behaviour 2's hard case: the tap has no build of its own to wait for, because + // the in-flight one already deploys. It must neither vanish (no switch) nor force + // a duplicate full rebuild behind a build that was about to satisfy it. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val launchesBefore = launches.size + val gate = CompletableDeferred() + executionGate = gate + + manager.save(sourceFile) + advanceUntilIdle() + manager.onQuickBuildTapped() + advanceUntilIdle() + // Still mid-build: no switch yet, and no second build queued behind this one. + assertThat(launches).hasSize(launchesBefore) + assertThat(executed).hasSize(1) + + gate.complete(Unit) + advanceUntilIdle() + + assertThat(launches).hasSize(launchesBefore + 1) + assertThat(executed).hasSize(1) + } + + @Test + fun `a tap with nothing to build switches immediately and runs no build at all`() = + runTest { + // Behaviour 4, sharpened by the F7 fix: with nothing written and nothing pending + // the deployed app is current, so the tap is answered by the switch alone - the + // forced redeploy that used to run behind it recompiled a whole module to deliver + // identical bytes. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val launchesBefore = launches.size + + manager.onQuickBuildTapped() + advanceUntilIdle() + + assertThat(executed).isEmpty() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + // And exactly once. + assertThat(launches).hasSize(launchesBefore + 1) + } + + @Test + fun `a tap that wrote something waits for its batch and switches when that build deploys`() = + runTest { + // The F7 root fix: the tap's save-all wrote files whose watcher batch is still in + // the coalescer window. The batch drives the one, correctly-routed build; the user + // switches when IT deploys - not before, and with no forced NoOp echo pair. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val launchesBefore = launches.size + + manager.onQuickBuildTapped(wroteSomething = true) + runCurrent() + // Armed, not answered: no build yet, no switch yet. + assertThat(executed).isEmpty() + assertThat(launches).hasSize(launchesBefore) + + // The save-all's batch lands (well inside the fallback deadline). + manager.save(sourceFile) + advanceUntilIdle() + + val request = executed.single() + assertThat(request.forced).isFalse() + assertThat(request.userInitiated).isTrue() + assertThat((request.changes as ChangedFiles.Known).files).containsExactly(sourceFile) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + // Exactly once, on the deploy - advanceUntilIdle already ran the deadline + // fallback's timer past its 2 s, so this also proves it did not double-switch. + assertThat(launches).hasSize(launchesBefore + 1) + } + + @Test + fun `a tap whose saves were all watcher-irrelevant still switches after the fallback deadline`() = + runTest { + // The .md-save edge: the save-all wrote something, but nothing the watcher reports, + // so no batch ever comes. The armed switch must fall back rather than leave the tap + // unanswered forever. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val launchesBefore = launches.size + + manager.onQuickBuildTapped(wroteSomething = true) + runCurrent() + assertThat(launches).hasSize(launchesBefore) + + // No batch arrives; the deadline answers the tap - once, with no build. + advanceTimeBy(2_001L) + runCurrent() + assertThat(launches).hasSize(launchesBefore + 1) + assertThat(executed).isEmpty() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + + // A save long after the expired tap is a plain save: builds, but never switches. + manager.save(sourceFile) + advanceUntilIdle() + assertThat(executed).hasSize(1) + assertThat(executed.single().userInitiated).isFalse() + assertThat(launches).hasSize(launchesBefore + 1) + } + + @Test + fun `the tap fallback does not switch before its 2 s deadline`() = + runTest { + // The F7 lower bound: the deadline must outlast the watcher's debounce and its + // mtime-poll emit window. Shortened under them, a slow batch gets the tap answered + // twice - the fallback switches, then the batch's own deploy switches again. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val launchesBefore = launches.size + + manager.onQuickBuildTapped(wroteSomething = true) + runCurrent() + + // Just under the deadline: still waiting on the batch, no switch yet. + advanceTimeBy(1_999L) + runCurrent() + assertThat(launches).hasSize(launchesBefore) + + // At exactly 2 s the deadline answers the tap. + advanceTimeBy(1L) + runCurrent() + assertThat(launches).hasSize(launchesBefore + 1) + } + + @Test + fun `a tap that starts a rebaseline waits for it instead of handing back the stale app`() = + runTest { + // Behaviour 4's exception, and the T8 bug (manual QA, 2026-08-11): a tap that lands on + // a full Gradle build cannot switch straight away. The app on the device is the one + // the rebaseline is replacing, so switching hands the user the stale build for the + // whole rebuild - and backgrounds CoGo, which is the only process that can raise the + // install confirmation the rebuild ends in. + var failProxyAppRebuild = true + proxyAppRebuildOutcome = { + if (failProxyAppRebuild) { + ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal("manifest does not build")) + } else { + defaultProxyAppRebuildSuccess() + } + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // Park the session on a rebaseline the user has to retry, which is the one place a + // tap is the thing that starts a full Gradle build. + manager.save(gradleFile) + advanceUntilIdle() + assertThat(manager.state.value) + .isEqualTo( + QuickBuildSessionState.Invalidated( + InvalidationReason.GRADLE_CONFIG_CHANGED, + 0, + awaitingRetry = true, + ), + ) + val launchesBefore = launches.size + + failProxyAppRebuild = false + val rebGate = CompletableDeferred() + proxyAppRebuildGate = rebGate + + manager.onQuickBuildTapped() + advanceUntilIdle() + + // Mid-rebaseline: the ask is held, not answered and not dropped. + assertThat(manager.state.value) + .isEqualTo( + QuickBuildSessionState.Provisioning( + rebaselineReason = InvalidationReason.GRADLE_CONFIG_CHANGED, + ), + ) + assertThat(launches).hasSize(launchesBefore) + + rebGate.complete(Unit) + advanceUntilIdle() + + // The rebaseline landed: its own relaunch brings the reinstalled app back + // (ADFA-4128: the rebaseline shares the restart deploy's launch path), and the + // deferred ask is answered exactly once on top of it. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(launches).hasSize(launchesBefore + 2) + } + + @Test + fun `a rebaseline that fails leaves the user in the editor where the error is`() = + runTest { + // The other half of T8: a deferred switch is dropped, not queued. The error lives in + // the editor's build output, and the app on the device is still the stale one. + proxyAppRebuildOutcome = { + ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal("manifest does not build")) + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(gradleFile) + advanceUntilIdle() + val launchesBefore = launches.size + + manager.onQuickBuildTapped() + advanceUntilIdle() + + assertThat(proxyAppRebuildCount).isEqualTo(2) + assertThat(manager.state.value) + .isEqualTo( + QuickBuildSessionState.Invalidated( + InvalidationReason.GRADLE_CONFIG_CHANGED, + 0, + awaitingRetry = true, + ), + ) + assertThat(launches).hasSize(launchesBefore) + } + + @Test + fun `a deferred foreground ask that has gone stale expires instead of yanking the user out of the editor`() = + runTest { + // F5 (manual QA, 2026-08-13): a rebaseline settled a 34-second-old ask on top of a + // user who had deliberately returned to the editor mid-typing. Past the age bound + // the ask no longer says where the user wants to be, so the landing build drops it. + var failProxyAppRebuild = true + proxyAppRebuildOutcome = { + if (failProxyAppRebuild) { + ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal("manifest does not build")) + } else { + defaultProxyAppRebuildSuccess() + } + } + val manager = createManager(nowMillis = { fakeNowMillis }) + manager.onQuickBuildTapped() + advanceUntilIdle() + + // Park on a failed rebaseline, then tap: the tap starts the retry and defers + // its foreground ask behind the full Gradle build. + manager.save(gradleFile) + advanceUntilIdle() + val launchesBefore = launches.size + failProxyAppRebuild = false + val rebGate = CompletableDeferred() + proxyAppRebuildGate = rebGate + + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(launches).hasSize(launchesBefore) + + // The rebaseline grinds on well past the point where the ask still means anything. + fakeNowMillis += 34_000L + rebGate.complete(Unit) + advanceUntilIdle() + + // The build landed fine - the rebaseline's own relaunch brings the reinstalled + // app back (one launch), but the stale ask expired rather than adding a second + // deferred switch on top. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(launches).hasSize(launchesBefore + 1) + } + + @Test + fun `a deferred foreground ask younger than the age bound is still answered when the build lands`() = + runTest { + // The boundary partner of the expiry test: a short rebaseline still owes the user + // the switch they asked for, so the expiry must not fire early. + var failProxyAppRebuild = true + proxyAppRebuildOutcome = { + if (failProxyAppRebuild) { + ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal("manifest does not build")) + } else { + defaultProxyAppRebuildSuccess() + } + } + val manager = createManager(nowMillis = { fakeNowMillis }) + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(gradleFile) + advanceUntilIdle() + val launchesBefore = launches.size + failProxyAppRebuild = false + val rebGate = CompletableDeferred() + proxyAppRebuildGate = rebGate + + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(launches).hasSize(launchesBefore) + + fakeNowMillis += 9_000L + rebGate.complete(Unit) + advanceUntilIdle() + + // The rebaseline's own relaunch plus the answered ask. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(launches).hasSize(launchesBefore + 2) + } + + @Test + fun `a deferred foreground ask at exactly the age bound is still answered`() = + runTest { + // The boundary itself (F5): expiry is age STRICTLY past the 10 s bound. With only + // the 34 s / 9 s pair above, a `>` to `>=` flip - or the bound quietly changing - + // keeps every test green. + var failProxyAppRebuild = true + proxyAppRebuildOutcome = { + if (failProxyAppRebuild) { + ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal("manifest does not build")) + } else { + defaultProxyAppRebuildSuccess() + } + } + val manager = createManager(nowMillis = { fakeNowMillis }) + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(gradleFile) + advanceUntilIdle() + val launchesBefore = launches.size + failProxyAppRebuild = false + val rebGate = CompletableDeferred() + proxyAppRebuildGate = rebGate + + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(launches).hasSize(launchesBefore) + + fakeNowMillis += 10_000L + rebGate.complete(Unit) + advanceUntilIdle() + + // The rebaseline's own relaunch plus the answered ask. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(launches).hasSize(launchesBefore + 2) + } + + @Test + fun `a deferred foreground ask one millisecond past the age bound expires`() = + runTest { + // The expiry partner of the exact-bound test: together they pin the constant at + // 10 s in both directions. + var failProxyAppRebuild = true + proxyAppRebuildOutcome = { + if (failProxyAppRebuild) { + ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal("manifest does not build")) + } else { + defaultProxyAppRebuildSuccess() + } + } + val manager = createManager(nowMillis = { fakeNowMillis }) + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(gradleFile) + advanceUntilIdle() + val launchesBefore = launches.size + failProxyAppRebuild = false + val rebGate = CompletableDeferred() + proxyAppRebuildGate = rebGate + + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(launches).hasSize(launchesBefore) + + fakeNowMillis += 10_001L + rebGate.complete(Unit) + advanceUntilIdle() + + // Only the rebaseline's own relaunch; the expired ask adds no second switch. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(launches).hasSize(launchesBefore + 1) + } + + @Test + fun `chained full builds settle the deferred ask exactly once, aged from the original tap`() = + runTest { + // The chained-build shape behind the re-defer question: a gradle edit mid-rebuild + // chains a second full build onto the first landing. The landing's settle runs + // before the chained invalidation can dispatch, so the ask is settled ONCE there, + // against the original tap's stamp - answered here (6 s old), and never again by + // the chained build's own landing. + var failProxyAppRebuild = true + proxyAppRebuildOutcome = { + if (failProxyAppRebuild) { + ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal("manifest does not build")) + } else { + defaultProxyAppRebuildSuccess() + } + } + val manager = createManager(nowMillis = { fakeNowMillis }) + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(gradleFile) + advanceUntilIdle() + val launchesBefore = launches.size + failProxyAppRebuild = false + val firstGate = CompletableDeferred() + proxyAppRebuildGate = firstGate + + // The tap defers its foreground ask behind the retry's full Gradle build. + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(launches).hasSize(launchesBefore) + + // A gradle edit mid-rebuild chains a second full build onto the landing. The mtime + // bump keeps the orchestrator's echo split from absorbing it into the running + // rebuild - this is a genuinely new edit, not the tap's own save echo. + gradleFile.setLastModified(System.currentTimeMillis() + 3_600_000L) + manager.save(gradleFile) + advanceUntilIdle() + + val secondGate = CompletableDeferred() + proxyAppRebuildGate = secondGate + fakeNowMillis += 6_000L + firstGate.complete(Unit) + advanceUntilIdle() + // The first landing relaunches the reinstalled app, and the 6-second-old ask is + // answered there, before the chained rebuild takes the session back to + // Provisioning. + assertThat(launches).hasSize(launchesBefore + 2) + assertThat(proxyAppRebuildCount).isEqualTo(3) + + fakeNowMillis += 6_000L + secondGate.complete(Unit) + advanceUntilIdle() + + // The chained landing relaunches its own reinstall, but must not answer the + // same tap twice. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(launches).hasSize(launchesBefore + 3) + } + + @Test + fun `a deferred ask stale at a chained landing expires and the chained build cannot revive it`() = + runTest { + // The audit's chained-build fear, pinned in its observable form: the first build + // runs the ask past the 10 s bound, and a chained full build is already queued + // when it lands. Expiry is judged against the ORIGINAL tap - so nothing may + // switch at the stale first landing, and the chained landing moments later must + // not resurrect the dead ask either. + var failProxyAppRebuild = true + proxyAppRebuildOutcome = { + if (failProxyAppRebuild) { + ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal("manifest does not build")) + } else { + defaultProxyAppRebuildSuccess() + } + } + val manager = createManager(nowMillis = { fakeNowMillis }) + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(gradleFile) + advanceUntilIdle() + val launchesBefore = launches.size + failProxyAppRebuild = false + val firstGate = CompletableDeferred() + proxyAppRebuildGate = firstGate + + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(launches).hasSize(launchesBefore) + + gradleFile.setLastModified(System.currentTimeMillis() + 3_600_000L) + manager.save(gradleFile) + advanceUntilIdle() + + val secondGate = CompletableDeferred() + proxyAppRebuildGate = secondGate + fakeNowMillis += 11_000L + firstGate.complete(Unit) + advanceUntilIdle() + // Stale at the first landing: its own relaunch runs, but the expired ask adds + // no deferred switch; chained rebuild under way. + assertThat(launches).hasSize(launchesBefore + 1) + assertThat(proxyAppRebuildCount).isEqualTo(3) + + fakeNowMillis += 2_000L + secondGate.complete(Unit) + advanceUntilIdle() + + // The chained landing is only moments after the expiry; it relaunches its own + // reinstall, but the ask stays dead. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(launches).hasSize(launchesBefore + 2) + } + + @Test + fun `a new ask after an expiry stamps a fresh clock and is answered normally`() = + runTest { + // Guards the other direction of the preserve-on-re-defer fix: the expiry nulls the + // stamp, so the next tap's ask must age from ITS OWN deferral, not the dead one's. + var failProxyAppRebuild = true + proxyAppRebuildOutcome = { + if (failProxyAppRebuild) { + ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal("manifest does not build")) + } else { + defaultProxyAppRebuildSuccess() + } + } + val manager = createManager(nowMillis = { fakeNowMillis }) + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(gradleFile) + advanceUntilIdle() + val launchesBefore = launches.size + failProxyAppRebuild = false + val firstGate = CompletableDeferred() + proxyAppRebuildGate = firstGate + + manager.onQuickBuildTapped() + advanceUntilIdle() + + // First ask goes stale and expires; only the landing's own relaunch runs. + fakeNowMillis += 34_000L + firstGate.complete(Unit) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(launches).hasSize(launchesBefore + 1) + + // Park again, then a fresh tap: 9 s is young against the new ask's own clock + // even though 43 s have passed since the expired one. + failProxyAppRebuild = true + manager.save(gradleFile) + advanceUntilIdle() + failProxyAppRebuild = false + val secondGate = CompletableDeferred() + proxyAppRebuildGate = secondGate + + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(launches).hasSize(launchesBefore + 1) + + fakeNowMillis += 9_000L + secondGate.complete(Unit) + advanceUntilIdle() + + // The second landing's relaunch plus the fresh ask, answered normally. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(launches).hasSize(launchesBefore + 3) + } + + @Test + fun `a stale reconnect catch-up build does not drag the user into the proxy app`() = + runTest { + // The catch-up build is forced, exactly like a tap - which is why "the user asked" + // cannot be read off BuildRequest.forced. Nobody tapped anything here. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.save(sourceFile) + advanceUntilIdle() + val launchesBefore = launches.size + + connections.onConnected(connectedAt(0)) + advanceUntilIdle() + + assertThat(executed).hasSize(2) + assertThat(launches).hasSize(launchesBefore) + } + + @Test + fun `stopping a build reports a cancellation, deploys nothing and keeps the pending edits`() = + runTest { + // Behaviour 5. Three claims: nothing deploys, the report is a NOTICE rather than an + // error, and the never-lose-pending invariant survives - the cancelled edit is + // rebuilt by the next save rather than dropped. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + val launchesBefore = launches.size + val gate = CompletableDeferred() + executionGate = gate + + manager.save(sourceFile) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Building(0)) + + manager.onCancelRequested() + advanceUntilIdle() + + // Back to the bolt at the generation the app still runs, with no failure: the + // user chose this, so it must not read as a broken build. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(manager.status.value).isEqualTo(QuickBuildStatus.UpToDate(0, null)) + assertThat(notices).containsExactly(QuickBuildNotice.BUILD_CANCELLED) + assertThat(userMessages).isEmpty() + + // Releasing the abandoned build must not resurrect its deploy. + gate.complete(Unit) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(launches).hasSize(launchesBefore) + + // The cancelled edit is still owed a build: the next save carries BOTH files. + executionGate = null + val other = + File(projectRoot, "app/src/main/java/com/example/Bar.kt").apply { writeText("class Bar") } + manager.save(other) + advanceUntilIdle() + assertThat((executed.last().changes as ChangedFiles.Known).files) + .containsExactly(sourceFile, other) + } + + @Test + fun `stopping is a no-op during the background warm compile - the user never asked for it`() = + runTest { + // The warm compile deploys nothing and the button shows the bolt throughout, so there is + // no build here for the user to cancel. Cancelling it would also throw away the + // daemon warm-up the next real save is about to need. + val gate = CompletableDeferred() + warmCompileGate = gate + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + assertThat(manager.state.value) + .isEqualTo(QuickBuildSessionState.Building(0, warmingCompiler = true)) + + manager.onCancelRequested() + advanceUntilIdle() + + assertThat(manager.state.value) + .isEqualTo(QuickBuildSessionState.Building(0, warmingCompiler = true)) + assertThat(notices).isEmpty() + + gate.complete(Unit) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `stopping a queued tap during prebuild cancels the Gradle proxy app build and never provisions`() = + runTest { + // Behaviour 5 mid-PROVISIONING. The proxy app build runs out of process behind a future, so + // abandoning the coroutine that awaits it would leave Gradle running while the + // button went idle - the cancel has to reach the tooling server. + val gate = CompletableDeferred() + prebuildGate = gate + val manager = createManager() + manager.prebuild() + advanceUntilIdle() + val notices = recordNotices(manager) + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(manager.state.value) + .isEqualTo(QuickBuildSessionState.Prebuilding(tapQueued = true)) + + manager.onCancelRequested() + advanceUntilIdle() + + assertThat(proxyAppBuildCancelCount).isEqualTo(1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(notices).containsExactly(QuickBuildNotice.BUILD_CANCELLED) + + // The queued tap went with the cancel: the warm build finishing must not now + // provision something the user just stopped. + gate.complete(Unit) + advanceUntilIdle() + assertThat(provisionCount).isEqualTo(0) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + } + + @Test + fun `stopping during provisioning cancels the proxy app build and tears the session down`() = + runTest { + val gate = CompletableDeferred() + provisionGate = gate + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + assertThat(manager.state.value) + .isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) + + manager.onCancelRequested() + advanceUntilIdle() + + assertThat(proxyAppBuildCancelCount).isEqualTo(1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(notices).containsExactly(QuickBuildNotice.BUILD_CANCELLED) + + // A provision that outlives the stop must not install itself as a zombie session. + gate.complete(Unit) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(watcher).isNull() + } + + /** + * A provision whose baseline declares [components] - the only fact the stale-helper + * warning keys on, since whether such a component is currently INSTANTIATED is unknowable + * from here. + */ + private fun provisionWithComponents(vararg components: ComponentInfo): ProvisionOutcome { + val base = defaultProvisionOutcome() as ProvisionOutcome.Success + return base.copy(proxyApp = base.proxyApp.copy(components = components.toList())) + } + + private val syncService = ComponentInfo(ComponentKind.SERVICE, "com.example.SyncService") + + private val logSenderService = + ComponentInfo(ComponentKind.SERVICE, "com.itsaky.androidide.logsender.LogSenderService") + private val logSenderInstaller = + ComponentInfo(ComponentKind.PROVIDER, "com.itsaky.androidide.logsender.utils.LogSenderInstaller") + + @Test + fun `a hot swap warns nothing when the only components are the ones CoGo injected`() = + runTest { + // Logsender is in every debuggable build, so warning on it would fire this notice on + // every ordinary app - about code the user did not write and cannot go stale. + provisionOutcome = { provisionWithComponents(logSenderInstaller, logSenderService) } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + + manager.save(sourceFile) + advanceUntilIdle() + + // The deploy really landed by hot swap - the silence below is a decision, not a no-op. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + assertThat(notices).isEmpty() + } + + @Test + fun `a user service still warns when CoGo's own components are alongside it`() = + runTest { + provisionOutcome = { provisionWithComponents(logSenderInstaller, syncService) } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + + manager.save(sourceFile) + advanceUntilIdle() + + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + assertThat(notices).containsExactly(QuickBuildNotice.STALE_COMPONENT_HELPERS) + } + + @Test + fun `a crashing reload tells the user how to recover, every time it crashes`() = + runTest { + // The accepted limitation is that a crashing payload redeploys and crashes again + // until the session is restarted. The bug was the SILENCE: the ATTENTION icon + // alone never says that only a session restart clears it. Repeated deliberately - + // each reload reproduces the crash, so each one has to say so. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + + connections.report(TargetReport.Crashed(0, "NPE in onCreate")) + advanceUntilIdle() + + assertThat(notices).containsExactly(QuickBuildNotice.RELOAD_CRASHED) + // The failure itself still lands on the status surface; the notice is the remedy, + // not a replacement for it. + assertThat(manager.status.value) + .isEqualTo(QuickBuildStatus.Failed(0, SessionFailure.ProxyAppCrash("NPE in onCreate"))) + // Not the error channel's business: userMessages is what the host flashes + // verbatim, and this copy lives in the app's string resources. + assertThat(userMessages).isEmpty() + + connections.report(TargetReport.Crashed(1, "NPE in onCreate")) + advanceUntilIdle() + assertThat(notices) + .containsExactly(QuickBuildNotice.RELOAD_CRASHED, QuickBuildNotice.RELOAD_CRASHED) + } + + @Test + fun `a hot-swap deploy warns once per session that a live service still calls the old code`() = + runTest { + provisionOutcome = { provisionWithComponents(syncService) } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + + manager.save(sourceFile) + advanceUntilIdle() + + // The deploy landed by hot swap (restarted = false), so the running service keeps + // calling the previous copies of whatever this build recompiled. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + assertThat(notices).containsExactly(QuickBuildNotice.STALE_COMPONENT_HELPERS) + + // Once per session: the gap holds for every later hot swap, and re-flashing it on + // each save would bury the notices that report something happening. + manager.save(sourceFile) + advanceUntilIdle() + assertThat(executed).hasSize(2) + assertThat(notices).containsExactly(QuickBuildNotice.STALE_COMPONENT_HELPERS) + } + + @Test + fun `a stale-helper warning nobody heard is still owed - the latch needs a listener`() = + runTest { + // This warning is raised by a hot-swap deploy, which lands while the user is in the + // PROXY APP - so the editor's lifecycle-bound collector is usually gone. The queue + // holds it for the collector that attaches next; what must not happen is the latch + // being spent on a warning nobody will ever hear, precisely in the case it was most + // needed. So: exactly one warning across both saves, and it arrives. + provisionOutcome = { provisionWithComponents(syncService) } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // No collector: CoGo is backgrounded, which is the normal state for this deploy. + manager.save(sourceFile) + advanceUntilIdle() + + val notices = recordNotices(manager) + manager.save(sourceFile) + advanceUntilIdle() + + assertThat(executed).hasSize(2) + assertThat(notices).containsExactly(QuickBuildNotice.STALE_COMPONENT_HELPERS) + } + + @Test + fun `a notice raised while CoGo is backgrounded is delivered when the editor returns`() = + runTest { + // The whole of C5: the only collector lives inside repeatOnLifecycle(STARTED), so it is + // gone for every notice raised while the user is in their proxy app - which is where a + // reload crash is raised by construction. The notice waits in the queue instead. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + connections.report(TargetReport.Crashed(0, "NPE in onCreate")) + advanceUntilIdle() + + val notices = recordNotices(manager) + advanceUntilIdle() + + assertThat(notices).containsExactly(QuickBuildNotice.RELOAD_CRASHED) + } + + @Test + fun `a notice already delivered is not repeated when the collector reattaches`() = + runTest { + // Why a queue and not replay = 1: the collector re-subscribes on EVERY transition back + // to STARTED, so a replayed notice would re-flash on each return to the editor - the + // same defect C22 reports for flashedFailure. Delivered exactly once, to one collector. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + val heard = mutableListOf() + val collector = + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + manager.notices.collect { heard += it } + } + connections.report(TargetReport.Crashed(0, "NPE in onCreate")) + advanceUntilIdle() + assertThat(heard).containsExactly(QuickBuildNotice.RELOAD_CRASHED) + + // CoGo goes to the background and comes back: a fresh collector on the same queue. + collector.cancelAndJoin() + val afterReturn = recordNotices(manager) + advanceUntilIdle() + + assertThat(afterReturn).isEmpty() + } + + @Test + fun `a failure raised while CoGo is backgrounded is flashed when the editor returns`() = + runTest { + // userMessages carries the same fix as notices, and needs it more: + // ReinstallReturnToCoGo asks the user to come back to CoGo, so by construction it is + // raised while they are not in CoGo. + provisionOutcome = { ProvisionOutcome.Failure(QuickBuildMessage.Literal("no build service")) } + val manager = createManager(collectUserMessages = false) + + manager.onQuickBuildTapped() + advanceUntilIdle() + + val heard = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + manager.userMessages.collect { heard += it } + } + advanceUntilIdle() + + assertThat(heard).containsExactly(QuickBuildMessage.Literal("no build service")) + } + + @Test + fun `a queued warning evicted before anyone hears it is owed again`() = + runTest { + // The queue is bounded and drops the oldest, so "queued" is not yet "heard". A latch + // that stayed set for an evicted warning would spend the one warning per session on + // nobody - the bug this fix exists to remove, moved one step later. + provisionOutcome = { provisionWithComponents(syncService) } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // Backgrounded hot swap: the warning is queued and the latch is set. + manager.save(sourceFile) + advanceUntilIdle() + + // Still backgrounded, the reload now crashes on every redeploy. Four newer notices + // fill the queue and push the warning out of it. + repeat(NOTICE_QUEUE_DEPTH) { generation -> + connections.report(TargetReport.Crashed(generation.toLong(), "NPE in onCreate")) + advanceUntilIdle() + } + + val notices = recordNotices(manager) + advanceUntilIdle() + assertThat(notices).doesNotContain(QuickBuildNotice.STALE_COMPONENT_HELPERS) + assertThat(notices).hasSize(NOTICE_QUEUE_DEPTH) + + // The eviction re-armed the latch, so the next hot swap warns. + manager.save(sourceFile) + advanceUntilIdle() + assertThat(notices.last()).isEqualTo(QuickBuildNotice.STALE_COMPONENT_HELPERS) + } + + @Test + fun `a repeating aapt2 rejection tells the user it is now blocking every save`() = + runTest { + // The relink links the whole res/ tree from disk, so an unlinkable resource fails + // every later build - including a pure-code save, whose own edit is fine. The status + // surface only ever shows the diagnostics, never that they are now stopping + // everything, and the case no edit can fix (a reference missing from the proxy app + // build's resource snapshot) then looks like the feature simply died. + val strings = File(projectRoot, "app/src/main/res/values/strings.xml") + val aapt2Error = + BuildOutcome.CompileError( + listOf( + BuildDiagnostic( + BuildDiagnostic.Severity.ERROR, + "resource style/Theme.Library not found", + strings.path, + 4, + 9, + ), + ), + ) + scriptedOutcomes += aapt2Error + scriptedOutcomes += aapt2Error + scriptedOutcomes += aapt2Error + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + + strings.parentFile!!.mkdirs() + strings.writeText("") + manager.save(strings) + advanceUntilIdle() + // One rejection is an ordinary compile error; the user is looking at the file. + assertThat(notices).isEmpty() + + // A pure-code save drags the still-pending resource back in and re-fails identically. + manager.save(sourceFile) + advanceUntilIdle() + assertThat(executed.last().route).isEqualTo(BuildRoute.CodeAndResources) + assertThat(notices).containsExactly(QuickBuildNotice.RELINK_STUCK) + + // Once per streak: the message asks the user to act, so repeating it on every save + // would train them to dismiss it. Nothing escalated - the session stays live at the + // old generation with the diagnostics on screen, never-stale intact. + manager.save(sourceFile) + advanceUntilIdle() + assertThat(notices).containsExactly(QuickBuildNotice.RELINK_STUCK) + assertThat(manager.state.value) + .isEqualTo( + QuickBuildSessionState.Ready(0, SessionFailure.CompileError(aapt2Error.diagnostics)), + ) + } + + @Test + fun `a restarting deploy does not warn about stale helpers - the process was relaunched`() = + runTest { + // The restart closure hit, so the whole process came back on the new payload. + // Warning here would be a lie about the one path that has no gap. + provisionOutcome = { provisionWithComponents(syncService) } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + scriptedOutcomes += BuildOutcome.Success(1, 5, restarted = true) + + manager.save(sourceFile) + advanceUntilIdle() + + assertThat(manager.state.value) + .isEqualTo(QuickBuildSessionState.Deployed(1, 5, restarted = true)) + assertThat(notices).isEmpty() + } + + @Test + fun `a resource-only deploy does not warn about stale helpers - no class was recompiled`() = + runTest { + // Nothing a component calls moved, so there is no stale copy to warn about. + provisionOutcome = { provisionWithComponents(syncService) } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + + val strings = + File(projectRoot, "app/src/main/res/values/strings.xml").apply { + parentFile!!.mkdirs() + writeText("") + } + + manager.save(strings) + advanceUntilIdle() + + assertThat(executed.single().route).isEqualTo(BuildRoute.ResourcesOnly) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + assertThat(notices).isEmpty() + } + + @Test + fun `an app with no restart-sensitive component never warns about stale helpers`() = + runTest { + // Activities and receivers are outside the restart closure because recreate and + // per-delivery instantiation already refresh them - nothing survives to go stale. + provisionOutcome = { + provisionWithComponents( + ComponentInfo(ComponentKind.ACTIVITY, "com.example.MainActivity", launcher = true), + ComponentInfo(ComponentKind.RECEIVER, "com.example.BootReceiver"), + ) + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + + manager.save(sourceFile) + advanceUntilIdle() + + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + assertThat(notices).isEmpty() + } + + @Test + fun `the stale-helper warning is owed again after a session restart`() = + runTest { + // Once per SESSION, not once per process: the next session may be a different + // project, and the user has to hear it there too. + provisionOutcome = { provisionWithComponents(syncService) } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + manager.save(sourceFile) + advanceUntilIdle() + assertThat(notices).containsExactly(QuickBuildNotice.STALE_COMPONENT_HELPERS) + + manager.restartSession() + advanceUntilIdle() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.save(sourceFile) + advanceUntilIdle() + + assertThat(notices) + .containsExactly( + QuickBuildNotice.STALE_COMPONENT_HELPERS, + QuickBuildNotice.STALE_COMPONENT_HELPERS, + ) + } + + @Test + fun `only the launcher activity is the relaunch target, not the first activity declared`() = + runTest { + // The manifest order is arbitrary, so picking the first ACTIVITY would foreground a + // splash/settings screen instead of the app's entry point. Only the MAIN/LAUNCHER + // one is a legitimate explicit target. + provisionOutcome = { + provisionWithComponents( + ComponentInfo(ComponentKind.ACTIVITY, "com.example.Splash", proxyClass = "com.example.QbSplash"), + ComponentInfo( + ComponentKind.ACTIVITY, + "com.example.Main", + proxyClass = "com.example.QbMain", + launcher = true, + ), + ) + } + val manager = createManager() + + manager.onQuickBuildTapped() + advanceUntilIdle() + + assertThat(launches).containsExactly("com.example.quickbuild" to "com.example.QbMain") + } + + @Test + fun `a refused foreground request is best-effort - no error surfaces and the session stays Ready`() = + runTest { + // The default launcher refuses (the app wires an intent-based one), and a refusal is + // not a build failure: the deploy already landed and the user can open the app + // themselves. Surfacing it would flash red for something that worked. + launchResult = false + val manager = createManager() + val notices = recordNotices(manager) + + manager.onQuickBuildTapped() + advanceUntilIdle() + + assertThat(launches).hasSize(1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(manager.status.value).isEqualTo(QuickBuildStatus.UpToDate(0, null)) + assertThat(userMessages).isEmpty() + assertThat(notices).isEmpty() + } + + @Test + fun `a second not-connected deploy tells the user the proxy app will not stay up`() = + runTest { + // A baseline that crashes at startup: the payload compiles and dexes fine and then + // has nowhere to land, and the deploy failure's own "relaunch to reconnect" advice + // just restarts the crash. Only a fresh proxy app build clears it, so the session + // has to say so rather than let the user loop. + val notConnected = BuildOutcome.DeployFailure("proxy app is not connected", proxyAppNotConnected = true) + scriptedOutcomes += notConnected + scriptedOutcomes += notConnected + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + + manager.save(sourceFile) + advanceUntilIdle() + // One failure is indistinguishable from an app the user happened to have closed. + assertThat(notices).isEmpty() + + manager.save(sourceFile) + advanceUntilIdle() + + assertThat(executed).hasSize(2) + assertThat(notices).containsExactly(QuickBuildNotice.PROXY_APP_WONT_STAY_UP) + // Nothing escalated: the session stays live at the generation the app last ran. + assertThat(manager.state.value) + .isEqualTo( + QuickBuildSessionState.Ready(0, SessionFailure.DeployError("proxy app is not connected")), + ) + assertThat(proxyAppRebuildCount).isEqualTo(0) + } + + @Test + fun `a failed daemon respawn surfaces the error and parks Degraded instead of auto-retrying`() = + runTest { + // Auto-retrying a hard-broken daemon would spin forever, so the session stays + // Degraded and waits for an explicit tap or a session restart. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + daemon.startReply = DaemonReply.Failed("daemon JVM would not start") + daemon.die(exitCode = 137) + advanceUntilIdle() + + // restartFailed is what makes the status stop claiming a restart is under way; the + // state is otherwise unchanged, and nothing is scheduled. + assertThat(manager.state.value) + .isEqualTo(QuickBuildSessionState.Degraded(0, restartFailed = true)) + assertThat(QuickBuildStatus.from(manager.state.value)) + .isEqualTo(QuickBuildStatus.Reconnecting(0, restartFailed = true)) + assertThat(userMessages) + .containsExactly(QuickBuildMessage.DaemonRestartFailed("daemon JVM would not start")) + assertThat(daemon.isRunning).isFalse() + // One respawn attempt, not a retry loop. + assertThat(daemon.startConfigs).hasSize(2) + + // A save while Degraded must not silently re-arm the respawn - still true, and this is + // the assertion that says so: no third daemon start. + // + // What the save DOES do is get narrated. The watcher never stopped, so the save really + // does start a quick build, and Degraded must follow that build's whole lifecycle - + // dropping it would leave the status on "restarting the compiler" while save after save + // produced nothing the user could see. + // + // It lands as Deployed here because this harness's executor is scripted independently of + // the daemon fake, so the build succeeds against a daemon that is down. On a device it + // fails with daemonDied, which arrives as DaemonDied from Building and parks back in + // Degraded with one more respawn - one per user save, which is not the auto-retry spin + // this test guards against. + manager.save(sourceFile) + advanceUntilIdle() + assertThat(daemon.startConfigs).hasSize(2) + assertThat(manager.state.value) + .isEqualTo(QuickBuildSessionState.Deployed(1, buildDurationMillis = 5)) + } + + @Test + fun `a respawned daemon that dies during its own start does not leave the session Ready`() = + runTest { + // The second-death race, which cannot be driven by hand on a device: the fresh child + // dies in the window between start() returning Ok and DaemonRespawned landing. The + // death arrives while the session is still Degraded, where it schedules nothing by + // design - so a DaemonRespawned taken at face value would announce a live compiler + // that is already gone, and the outage would stay hidden until the next save. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + daemon.onStart = { + daemon.onStart = {} + // Fire the death, then yield so its dispatch lands before start returns - the + // ordering a real spawn produces, and the one the bug needs. + daemon.die(exitCode = 1) + yield() + } + daemon.die(exitCode = 137) + advanceUntilIdle() + + assertThat(manager.state.value) + .isEqualTo(QuickBuildSessionState.Degraded(0, restartFailed = true)) + assertThat(daemon.isRunning).isFalse() + // One respawn attempt for the first death and one for the second-death report is not + // what happens: Degraded schedules nothing on DaemonDied, so the count stays at the + // provision start plus the single respawn. That is the no-spin property. + assertThat(daemon.startConfigs).hasSize(2) + + // And the gesture the status now names really does retry. + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(daemon.startConfigs).hasSize(3) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `a stop that lost the race to the build's own completion reports no cancellation`() = + runTest { + // The stop reached the reducer while the build was still in flight, but the build + // finished before the effect ran. Nothing was cancelled, so saying "cancelled" + // would be a lie about a build that actually landed. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + val gate = CompletableDeferred() + executionGate = gate + + manager.save(sourceFile) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Building(0)) + + // The stop is queued first; the build's completion runs between it and its effect. + manager.onCancelRequested() + gate.complete(Unit) + advanceUntilIdle() + + assertThat(executed).hasSize(1) + assertThat(notices).isEmpty() + } + + @Test + fun `a tap that lost the race to its build's completion is still answered - by the switch`() = + runTest { + // The reducer decided to hang the ask on the in-flight build, but that build + // finished before the effect ran. Falling back to a real request is what keeps the + // tap from vanishing - and with nothing pending, that request now answers the tap + // by switching, instead of paying a forced NoOp rebuild of identical bytes. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val launchesBefore = launches.size + val gate = CompletableDeferred() + executionGate = gate + + manager.save(sourceFile) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Building(0)) + + manager.onQuickBuildTapped() + gate.complete(Unit) + advanceUntilIdle() + + // No second build ran, and the tap got its answer exactly once. + assertThat(executed).hasSize(1) + assertThat(launches).hasSize(launchesBefore + 1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + } + + @Test + fun `a tap during the warm compile with a pending save waits for that build's deploy`() = + runTest { + // Unlike a tap with nothing pending, this one has a real build to wait for, so + // foregrounding now would put the user in front of the OLD code and then reload it + // under them. The switch belongs on the deploy. + val manager = createManager() + val gate = CompletableDeferred() + warmCompileGate = gate + manager.onQuickBuildTapped() + advanceUntilIdle() + val launchesBefore = launches.size + + manager.save(sourceFile) + advanceUntilIdle() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // Still queued behind the warm compile, and the user is still in the editor. + assertThat(executed).isEmpty() + assertThat(launches).hasSize(launchesBefore) + + gate.complete(Unit) + advanceUntilIdle() + + val request = executed.single() + // The tap no longer forces: the pending save routes the build like any other. + assertThat(request.forced).isFalse() + assertThat((request.changes as ChangedFiles.Known).files).containsExactly(sourceFile) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + // Exactly once, when the deploy landed. + assertThat(launches).hasSize(launchesBefore + 1) + } + + @Test + fun `a stop with no Gradle build left to cancel still reports it and tears the session down`() = + runTest { + // The Gradle build had already finished and the session is in its install or + // daemon-spawn tail. The user pressed stop and the session does stop, so the + // report is owed whether or not the cancellation reached Gradle. + provisionGate = CompletableDeferred() + proxyAppBuildCancelResult = false + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + assertThat(manager.state.value) + .isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) + + manager.onCancelRequested() + advanceUntilIdle() + + assertThat(proxyAppBuildCancelCount).isEqualTo(1) + assertThat(notices).containsExactly(QuickBuildNotice.BUILD_CANCELLED) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + + // The provision that outlived the stop must not install itself behind an Idle UI. + provisionGate!!.complete(Unit) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(daemon.isRunning).isFalse() + } + + @Test + fun `an unconfirmed reinstall parks at the generation the app runs, not the allocator's`() = + runTest { + // The two genuinely differ: the allocator persists across sessions and burns + // numbers on builds that never deployed, while the park has to name what the proxy + // app is actually running so the banner does not claim a generation nobody has. + scriptedOutcomes += BuildOutcome.Success(2, 5) + proxyAppRebuildOutcome = { + ProxyAppRebuildOutcome.InstallNotConfirmed(QuickBuildMessage.Literal("install was not confirmed")) + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.save(sourceFile) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(2, 5)) + // The allocator never moved, so it and the deploy tally now disagree - which is the + // whole point of reading the tally here. + assertThat(store.value).isNull() + + manager.save(gradleFile) + advanceUntilIdle() + + assertThat(manager.state.value) + .isEqualTo( + QuickBuildSessionState.Invalidated( + InvalidationReason.INSTALL_NOT_CONFIRMED, + 2, + awaitingRetry = true, + ), + ) + } + + @Test + fun `a messageless throw during the rebuild's re-baseline surfaces the exception class name`() = + runTest { + // A bare `checkNotNull` / NPE carries no message; surfacing an empty string would + // flash a blank banner and tell the user nothing at all. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + executorFactoryError = { IllegalStateException() } + manager.save(gradleFile) + advanceUntilIdle() + + assertThat(userMessages) + .contains(QuickBuildMessage.Literal("java.lang.IllegalStateException")) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle(lastStartFailed = true)) + } + + @Test + fun `the proxy app disconnecting is not a crash and triggers no catch-up build`() = + runTest { + // The user swiped the app away, or it was killed for memory. Nothing is running to + // be behind, so treating the disconnect as a stale reconnect would rebuild and + // redeploy into thin air, and treating the report as a crash would flash red. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + + manager.save(sourceFile) + advanceUntilIdle() + connections.onConnected(connectedAt(1)) + advanceUntilIdle() + assertThat(executed).hasSize(1) + + connections.onDisconnected() + advanceUntilIdle() + + assertThat(executed).hasSize(1) + assertThat(notices).isEmpty() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + } + + @Test + fun `a proxy app rebuild that outlives a session restart never re-baselines the dead session`() = + runTest { + // The Gradle build runs out of process, so "Restart session" cannot un-run it. Its + // late success must not restart a daemon or move a session that is already gone. + proxyAppRebuildGate = CompletableDeferred() + proxyAppRebuildSurvivesCancel = true + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(factoryProxyApps).hasSize(1) + + manager.save(gradleFile) + advanceUntilIdle() + assertThat(manager.state.value) + .isEqualTo( + QuickBuildSessionState.Provisioning( + rebaselineReason = InvalidationReason.GRADLE_CONFIG_CHANGED, + ), + ) + + manager.restartSession() + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + + proxyAppRebuildGate!!.complete(Unit) + advanceUntilIdle() + + // Discarded before the daemon restart and before any executor was rebuilt. + assertThat(daemon.startConfigs).hasSize(1) + assertThat(factoryProxyApps).hasSize(1) + assertThat(daemon.isRunning).isFalse() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + manager.save(sourceFile) + advanceUntilIdle() + assertThat(executed).isEmpty() + } + + @Test + fun `a session restart during the daemon start stops the daemon that start brings up`() = + runTest { + // Cancellation is cooperative, so a daemon spawn already under way still finishes + // and leaves a JVM holding ~0.5GB behind an Idle UI. Nothing else owns it. + val startGate = CompletableDeferred() + daemon.startGate = startGate + daemon.startSurvivesCancel = true + val manager = createManager() + + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(daemon.startConfigs).hasSize(1) + assertThat(daemon.isRunning).isFalse() + + manager.restartSession() + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + + // The start finally completes into a session that no longer exists. + startGate.complete(Unit) + advanceUntilIdle() + + assertThat(daemon.isRunning).isFalse() + assertThat(daemon.startConfigs).hasSize(1) + assertThat(connections.expectedPackage).isNull() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(warmCompiles).isEmpty() + } + + @Test + fun `a session restart racing a build in flight leaves nothing to report`() = + runTest { + // The restart lands between the build starting and its events being applied, so the + // events arrive with no session behind them: no tally to advance, no status to push + // to a proxy app this session no longer owns, and no hot-swap warning to give. + provisionOutcome = { provisionWithComponents(syncService) } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + deploy.statusCalls.clear() + + // Both are queued before either runs: the save starts a build, the restart tears + // the session down while that build's events are still in the queue behind it. + manager.save(sourceFile) + manager.restartSession() + advanceUntilIdle() + + assertThat(executed).hasSize(1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + assertThat(manager.status.value).isEqualTo(QuickBuildStatus.Hidden()) + assertThat(deploy.statusCalls).isEmpty() + assertThat(notices).isEmpty() + } + + @Test + fun `a restart landing on the heels of provisioning skips the background warm compile`() = + runTest { + // The warm compile is launched, not run inline, precisely so a teardown queued + // behind the provision wins: warming a daemon for a session nobody can use burns + // 12-50s of CPU on a device that just asked for everything to stop. + provisionGate = CompletableDeferred() + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + provisionGate!!.complete(Unit) + manager.restartSession() + advanceUntilIdle() + + assertThat(warmCompiles).isEmpty() + assertThat(executed).isEmpty() + assertThat(daemon.isRunning).isFalse() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + } + + @Test + fun `a tap withdrawn by a restart never reaches the orchestrator`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val launchesBefore = launches.size + + // The user taps, then immediately long-presses Restart session. + manager.onQuickBuildTapped() + manager.restartSession() + advanceUntilIdle() + + assertThat(executed).isEmpty() + assertThat(launches).hasSize(launchesBefore) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + } + + @Test + fun `a tap withdrawn by a restart mid-build does not promote the abandoned build`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val launchesBefore = launches.size + val gate = CompletableDeferred() + executionGate = gate + + manager.save(sourceFile) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Building(0)) + + manager.onQuickBuildTapped() + manager.restartSession() + advanceUntilIdle() + + // Neither a second build for the tap nor a foregrounding of a torn-down session. + gate.complete(Unit) + advanceUntilIdle() + assertThat(executed).hasSize(1) + assertThat(launches).hasSize(launchesBefore) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + } + + @Test + fun `a stop withdrawn by a restart reports no cancellation - the restart already said it`() = + runTest { + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val notices = recordNotices(manager) + val gate = CompletableDeferred() + executionGate = gate + + manager.save(sourceFile) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Building(0)) + + manager.onCancelRequested() + manager.restartSession() + advanceUntilIdle() + + gate.complete(Unit) + advanceUntilIdle() + assertThat(notices).isEmpty() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + } + + @Test + fun `a Standard Run finishing after a session restart refreshes nothing`() = + runTest { + // The Run button's build-finished hook fires whether or not Quick Build is still + // alive; with the session gone there is no baseline to mark dirty. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + manager.onStandardRunCompleted() + manager.restartSession() + advanceUntilIdle() + + assertThat(proxyAppRebuildCount).isEqualTo(0) + assertThat(executed).isEmpty() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + + // And the next tap still provisions a healthy session. + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `a daemon death answered by a restart never respawns the daemon`() = + runTest { + // The user hit Restart session because the daemon died. Respawning one for the dead + // session would leave a JVM up with nothing to compile for. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(daemon.startConfigs).hasSize(1) + + daemon.die(exitCode = 137) + manager.restartSession() + advanceUntilIdle() + + assertThat(daemon.startConfigs).hasSize(1) + assertThat(daemon.isRunning).isFalse() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + } + + @Test + fun `a batch already in flight when the watcher stopped builds nothing`() = + runTest { + // inotify cannot unwind a callback that is mid-delivery, so a batch can reach the + // manager after the teardown that stopped its watcher. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val stoppedWatcher = watcher!! + + manager.restartSession() + advanceUntilIdle() + + stoppedWatcher.emitRacingStop(setOf(sourceFile)) + advanceUntilIdle() + + assertThat(executed).isEmpty() + assertThat(proxyAppRebuildCount).isEqualTo(0) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + } + + @Test + fun `a teardown finishing after a new session went live keeps that session's scratch tree`() = + runTest { + // The teardown's tree removal waits on the daemon shutdown, which can outlast a + // re-tap. Removing then would delete the live session's compile outputs out from + // under it - the tree belongs to whoever is live now, not to whoever queued it. + val scratchRoot = FakePaths(projectRoot).projectScratchRoot + val tree = QuickBuildScratch(scratchRoot).treeFor(projectRoot) + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(tree.isDirectory).isTrue() + + // Hold the teardown inside the daemon shutdown it waits on. + val shutdownGate = CompletableDeferred() + daemon.shutdownGate = shutdownGate + manager.restartSession() + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + + // A new session for the SAME project goes live while that teardown is parked. + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + + shutdownGate.complete(Unit) + advanceUntilIdle() + + assertThat(tree.isDirectory).isTrue() + // And the new session is still usable, not compiling into a deleted tree. + manager.save(sourceFile) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) + } +} From 5f581aed400e4177beb4d3406b5453090c676fc5 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 22:47:37 -0700 Subject: [PATCH 2/2] =?UTF-8?q?ADFA-4128:=20qb=2008=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20cancel=20sequencing,=20provision=20tail,=20deploy-t?= =?UTF-8?q?hrow=20containment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stale cancel flag: the Prebuilding stop latches proxyAppBuildCancelIssued with no teardown to clear it, so a later "Restart session" skipped the Gradle cancel -> clear the flag whenever an effect launches new session work (StartProvisioning / StartProxyAppPrebuild / RunProxyAppRebuild); covered by "a session started after a prebuild-stop still gets its Gradle build cancelled on restart". Unguarded provision-success tail: retention clear, generation adoption and watcher.start ran unguarded on a scope with no CoroutineExceptionHandler -> wrap the tail in the same try/catch -> ProvisioningFailed boundary the rebuild arm already uses; covered by "a watcher-start throw in provisioning's success tail fails the session instead of escaping". Collector-killing deploy throw: resendRetainedPayload called deploy.deploy() bare inside the init-launched reconnect collector, so one throw disabled catch-up for the process -> contain non-cancellation throwables as a failed re-send (return false, fall back to the catch-up build); covered by "a throwing re-send is contained - catch-up falls back now and stays alive for later reconnects". Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W --- .../session/QuickBuildSessionManager.kt | 87 ++++++++++---- .../cotg/quickbuild/service/Fakes.kt | 7 ++ .../session/QuickBuildSessionManagerTest.kt | 111 +++++++++++++++++- 3 files changed, 179 insertions(+), 26 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManager.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManager.kt index 2333e551c2..4c95e74350 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManager.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManager.kt @@ -259,8 +259,10 @@ class QuickBuildSessionManager( * Whether [SessionEffect.CancelProxyAppBuild] already cancelled this session's Gradle build, * so [teardown] does not ask a second time. The stop-tap path emits that effect and a * teardown; every OTHER teardown (a restart, an invalidation, a project close) emits only the - * teardown, which is the case teardown's own cancel exists for. Cleared in [teardown], which - * always follows the effect. + * teardown, which is the case teardown's own cancel exists for. Cleared in [teardown], and + * again whenever an effect launches new session work: the Prebuilding stop drops a queued + * tap with the cancel effect but NO teardown, and left latched the flag would make the next + * session's teardown skip the cancel of a Gradle build it never covered. */ private var proxyAppBuildCancelIssued = false @@ -654,11 +656,16 @@ class QuickBuildSessionManager( private fun runEffect(effect: SessionEffect) { when (effect) { SessionEffect.StartProvisioning -> { + // A cancel issued against a previous build does not cover the one starting + // here; see [proxyAppBuildCancelIssued] for the Prebuilding stop that + // latches it with no teardown to clear it. + proxyAppBuildCancelIssued = false val epoch = sessionEpoch sessionWork = scope.launch { provision(epoch) } } SessionEffect.StartProxyAppPrebuild -> { + proxyAppBuildCancelIssued = false sessionWork = scope.launch { runPrebuild() } } @@ -718,6 +725,7 @@ class QuickBuildSessionManager( } SessionEffect.RunProxyAppRebuild -> { + proxyAppBuildCancelIssued = false val epoch = sessionEpoch sessionWork = scope.launch { rebuildProxyApp(epoch) } } @@ -964,22 +972,35 @@ class QuickBuildSessionManager( live = result.session staleComponentHelpersNoticed = false testSourceIgnoredNoticed = false - // A same-project predecessor's scratch tree can survive its teardown (see - // [teardown]'s skip when a new session went live mid-shutdown); whatever it - // retained belongs to another baseline and must not answer this session's - // reconnects. - result.session.retainedPayloads.clear() - // The installed APK boots at the stamped baseline generation (concurrency.md - // rule 2): the allocator must stay strictly above it, and adopting it as the - // deploy tally makes a reconnect at the stamp read in-sync by construction. - result.tracker.adoptAtLeast(result.baselineGeneration) - result.session.lastDeployedGeneration = result.baselineGeneration - // Build ids restart per session; give the sink its session boundary. - report { metrics.onSessionStarted() } - // The reload path is change-driven, not save-driven: any source of a - // file change triggers it, including Termux, plugins and git. - result.session.watcher.start(::onWatcherBatch) - dispatch(SessionEvent.ProvisioningSucceeded(result.baselineGeneration)) + try { + // A same-project predecessor's scratch tree can survive its teardown (see + // [teardown]'s skip when a new session went live mid-shutdown); whatever it + // retained belongs to another baseline and must not answer this session's + // reconnects. + result.session.retainedPayloads.clear() + // The installed APK boots at the stamped baseline generation (concurrency.md + // rule 2): the allocator must stay strictly above it, and adopting it as the + // deploy tally makes a reconnect at the stamp read in-sync by construction. + result.tracker.adoptAtLeast(result.baselineGeneration) + result.session.lastDeployedGeneration = result.baselineGeneration + // Build ids restart per session; give the sink its session boundary. + report { metrics.onSessionStarted() } + // The reload path is change-driven, not save-driven: any source of a + // file change triggers it, including Termux, plugins and git. + result.session.watcher.start(::onWatcherBatch) + dispatch(SessionEvent.ProvisioningSucceeded(result.baselineGeneration)) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Throwable) { + // The runner's error boundary ends at its outcome; this tail (retention + // IO, the persisted generation store, the FileObserver registration) is + // the manager's half of the same assembly, and a throw here would escape + // to a scope with no CoroutineExceptionHandler and crash CoGo with the + // daemon up and the uid session registered. [live] is already set, so the + // failure effect's teardown unwinds both. + log.error("Installing the provisioned quick-build session threw", e) + dispatch(SessionEvent.ProvisioningFailed(QuickBuildMessage.Literal(e.message ?: e.javaClass.name))) + } } } } @@ -1268,13 +1289,29 @@ class QuickBuildSessionManager( retained.generation, ) val result = - deploy.deploy( - retained.generation, - retained.dexFile, - retained.arscFile, - retained.assetsZip, - retained.metadataJson, - ) + try { + deploy.deploy( + retained.generation, + retained.dexFile, + retained.arscFile, + retained.assetsZip, + retained.metadataJson, + ) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Throwable) { + // deploy() is throw-capable (see notifyBuilding's guard), and this runs + // inside the reconnect collector launched once in [init]: an escaping + // throw would kill that collector for the rest of the process, and every + // later stale reconnect would run old code silently - the exact failure + // the collector exists to prevent. Contain it as a failed re-send. + log.warn( + "Re-send of retained generation {} threw; falling back to a catch-up build", + retained.generation, + e, + ) + return false + } if (result is DeployResult.Reloaded) return true log.warn( "Re-send of retained generation {} failed ({}); falling back to a catch-up build", diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt index eb50201192..a9b89daef0 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt @@ -150,6 +150,12 @@ class FakeDeploy : DeploySender { val resultQueue = ArrayDeque() var disconnects: Boolean = true + /** + * When set, every [deploy] throws it after recording the call. Stands in for a binder + * edge the sender did not classify into a [DeployResult] - the contract is throw-capable. + */ + var deployError: Throwable? = null + /** * Generation the fake "relaunched app" reconnects at, given the last deployed * generation; return null for a relaunch that never reconnects. Defaults to a @@ -165,6 +171,7 @@ class FakeDeploy : DeploySender { metadataJson: String, ): DeployResult { calls += Call(generation, dexFile, arscFile, assetsZip, metadataJson) + deployError?.let { throw it } return resultQueue.removeFirstOrNull() ?: result } diff --git a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManagerTest.kt b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManagerTest.kt index 265dc31728..7c1803fae6 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManagerTest.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManagerTest.kt @@ -167,6 +167,12 @@ class QuickBuildSessionManagerTest { /** Captures the watcher the manager builds so a test can push change batches. */ private var watcher: FakeWatcher? = null + /** + * When set, the manager-built watcher's [ProjectWatcher.start] throws it. Stands in for a + * real FileObserver/inotify registration failure in provisioning's success tail. + */ + private var watcherStartError: (() -> Throwable)? = null + /** * Every request to bring the proxy app to the foreground, as (package, launcherActivity). * Behaviours 2/3/4 are exactly "is this list empty, and when did it grow", so it is the @@ -196,6 +202,7 @@ class QuickBuildSessionManagerTest { */ private class FakeWatcher( private val filter: WatchFilter, + private val startError: () -> Throwable? = { null }, ) : ProjectWatcher { private var onBatch: ((ChangedFiles.Known) -> Unit)? = null @@ -203,6 +210,7 @@ class QuickBuildSessionManagerTest { private var lastOnBatch: ((ChangedFiles.Known) -> Unit)? = null override fun start(onBatch: (ChangedFiles.Known) -> Unit) { + startError()?.let { throw it } this.onBatch = onBatch this.lastOnBatch = onBatch } @@ -349,7 +357,9 @@ class QuickBuildSessionManagerTest { } } }, - watcherFactory = { _, _, filter, _ -> FakeWatcher(filter).also { watcher = it } }, + watcherFactory = { _, _, filter, _ -> + FakeWatcher(filter, { watcherStartError?.invoke() }).also { watcher = it } + }, metrics = recordingMetrics, warmCompileEnabled = warmCompileEnabled, nowMillis = nowMillis, @@ -2220,6 +2230,39 @@ class QuickBuildSessionManagerTest { assertThat(executed.last().forced).isTrue() } + @Test + fun `a throwing re-send is contained - catch-up falls back now and stays alive for later reconnects`() = + runTest { + // deploy() is throw-capable, and the re-send runs inside the reconnect + // collector launched once in init: an escaping throw would kill that + // collector for the rest of the process, and every later stale reconnect + // would run old code silently - the exact failure it exists to prevent. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.save(sourceFile) + advanceUntilIdle() + assertThat(executed).hasSize(1) + + seedRetainedPayload(generation = 1L) + deploy.deployError = RuntimeException("binder transaction failed") + connections.onConnected(connectedAt(0)) + advanceUntilIdle() + + // Contained like any other failed re-send: attempted once, then the + // last-resort forced rebuild of current sources. + assertThat(deploy.calls).hasSize(1) + assertThat(executed).hasSize(2) + assertThat(executed.last().forced).isTrue() + + // The collector survived: the next stale reconnect is still repaired. + deploy.deployError = null + connections.onConnected(connectedAt(0)) + advanceUntilIdle() + assertThat(executed).hasSize(3) + assertThat(executed.last().forced).isTrue() + } + @Test fun `retention from an older deploy is never replayed - the forced build repairs instead`() = runTest { @@ -3830,6 +3873,45 @@ class QuickBuildSessionManagerTest { assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) } + @Test + fun `a session started after a prebuild-stop still gets its Gradle build cancelled on restart`() = + runTest { + // The Prebuilding stop above latches the cancel-issued flag with no teardown to + // clear it. Left stale, the NEXT session's teardown would skip the Gradle + // cancel, the orphaned build would keep the device's one build slot, and the + // user's "Restart session" would come back as a SlotBusy setup failure. + prebuildGate = CompletableDeferred() + val manager = createManager() + manager.prebuild() + advanceUntilIdle() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.onCancelRequested() + advanceUntilIdle() + assertThat(proxyAppBuildCancelCount).isEqualTo(1) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + prebuildGate!!.complete(Unit) + advanceUntilIdle() + + // A fresh tap owns a fresh Gradle proxy app build... + provisionGate = CompletableDeferred() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(manager.state.value) + .isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) + + // ...so the restart's teardown must reach the Gradle cancel: nothing else + // releases the build slot for the reprovision it goes on to run. + manager.restartSessionAndReprovision() + advanceUntilIdle() + assertThat(proxyAppBuildCancelCount).isEqualTo(2) + + // And the reprovision itself still lands. + provisionGate!!.complete(Unit) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + @Test fun `stopping during provisioning cancels the proxy app build and tears the session down`() = runTest { @@ -3856,6 +3938,33 @@ class QuickBuildSessionManagerTest { assertThat(watcher).isNull() } + @Test + fun `a watcher-start throw in provisioning's success tail fails the session instead of escaping`() = + runTest { + // The install tail after a successful provision (retention clear, generation + // adoption, watcher registration) runs on a scope with no + // CoroutineExceptionHandler: an escaping throw would crash CoGo with the + // daemon up and the uid session registered, and strand the machine in + // Provisioning. + watcherStartError = { IllegalStateException("inotify watch limit reached") } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + // Same path as any other failed provision: torn down clean to Idle with the + // error surfaced and the daemon down - never a crash or a wedged Provisioning. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle(lastStartFailed = true)) + assertThat(userMessages).contains(QuickBuildMessage.Literal("inotify watch limit reached")) + assertThat(daemon.isRunning).isFalse() + + // The next tap re-provisions from scratch - not wedged. + watcherStartError = null + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(provisionCount).isEqualTo(2) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + /** * A provision whose baseline declares [components] - the only fact the stale-helper * warning keys on, since whether such a component is currently INSTANTIATED is unknowable