From d5ccfbab867d08e576306cd2f6471289262f8c57 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 15:03:33 -0700 Subject: [PATCH 1/9] =?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 f737b85a32..8ed4fca040 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 @@ -14,6 +14,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. */ @@ -212,3 +213,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 fc4692ef7361e24bcd82d634ebc6411ac7a31e7a Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 22:47:37 -0700 Subject: [PATCH 2/9] =?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 8ed4fca040..27ddb579ab 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 @@ -153,6 +153,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 @@ -168,6 +174,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 From 20add001030c97f88f0bbf12412c750e5fa3dc8a Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 26 Aug 2026 23:43:46 -0700 Subject: [PATCH 3/9] ADFA-4128 (8/11): address CodeRabbit review - F1720-1 draw the eight transitions the authoritative diagram omitted Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FstXxJ5cwWPcvmhZ9vJgJ7 --- .../appdevforall/cotg/quickbuild/domain/session/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) 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 index 6e221077b0..3f030e5dfb 100644 --- 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 @@ -60,10 +60,18 @@ stateDiagram-v2 Invalidated --> Provisioning: ProxyAppRebuildStarted Invalidated --> Invalidated: QuickBuildTapped / HostForegrounded (RunProxyAppRebuild) + Invalidated --> Building: BuildStarted (awaiting retry) + Invalidated --> Deployed: BuildSucceeded (awaiting retry) + Invalidated --> Ready: BuildFailed (awaiting retry) + Invalidated --> Invalidated: DaemonDied (awaiting retry, RespawnDaemon) Degraded --> Ready: DaemonRespawned Degraded --> Invalidated: InvalidationDetected Degraded --> Degraded: ExternalBuildCompleted (RefreshBaseline) + Degraded --> Degraded: QuickBuildTapped (SurfaceMessage + RespawnDaemon) + Degraded --> Building: BuildStarted + Degraded --> Deployed: BuildSucceeded + Degraded --> Ready: BuildFailed note right of Idle SessionRestartRequested from any From 98ebc6bc8186856cacf3230b1c1f690aa3c7d602 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 31 Aug 2026 18:48:07 -0700 Subject: [PATCH 4/9] ADFA-4128: qb-08 review fixes - foreground policy, slot-busy park, named fallbacks Applies the fix-now items from the 2026-08-31 review triage. Foreground policy (Bryan, 2026-08-31): the proxy app comes forward only for a user's Quick Build tap, and then exactly when enough building has happened to carry their changes. - A successful rebaseline with no user ask outstanding reconnects in the background instead of relaunching the app (runner gains a userAskOutstanding gate). - A tap during a save-triggered rebaseline is recorded (Provisioning.userInitiated) and honoured when the rebuild lands, instead of being dropped. - A rebaseline ask is exempt from the 10 s deferred-ask expiry - the bound stays for non-rebaseline asks (foregroundAskAwaitsRebaseline). - A variant-switch reprovision dispatches userInitiated = false (SessionRestartAndReprovisionRequested is now a data class carrying the flag); the menu/dialog restart stays explicit true. Other fixes: - A FIRST proxy app rebuild that loses the Gradle slot parks recoverable (awaitingRetry) instead of dying to Idle with a failure banner. - A Degraded tap only respawns the daemon when restartFailed; while the DaemonDied respawn is in flight it acks without racing a second respawn (respawns never bump the daemon epoch, so they would race, not supersede). - Messageless throws surface named messages (new QuickBuildMessage.ProvisioningFailedUnexpectedly, or RebuildFailed for rebuild paths) instead of a raw exception class name; the class and stack stay in the error log. - ProxyAppInfo.launcherProxyClass gives the launch target one home shared by restart deploy, rebuild relaunch and the foreground switch. - domain/session README state diagram redrawn from the post-fix reducer, adding the transitions the review found missing. RESTACK NOTE for qb-11: QuickBuildMessage gains ProvisioningFailedUnexpectedly, so the app-module mapper QuickBuildMessages.resolve (exhaustive when) will fail to compile until it adds the new case - the loud break that mapper's design intends. Tests: red-first (12 predicted failures observed), then green - :quickbuild:core:testV8DebugUnitTest, 1128 tests pass. Two obsolete expiry tests deleted (chained-landing expiry, fresh-clock-after-expiry): both pin the removed rebaseline expiry. Also: plain-language pass over the comments added by these fixes Also: honour a deferred rebaseline ask once, not twice (code review 09-01, important 2). ProxyAppRebuildResult.Succeeded.answeredUserAsk tells the manager the runner's relaunch already answered the ask, and it clears the deferred ask before the landing dispatches, so Ready does not launch the app a second time for the same tap. Seven launch-count assertions go from two launches to one. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01STCsdMzx9daNBcqMN424Ci --- .../cotg/quickbuild/data/ProxyAppInfo.kt | 11 + .../domain/session/QuickBuildMessage.kt | 9 + .../domain/session/QuickBuildSessionState.kt | 10 +- .../cotg/quickbuild/domain/session/README.md | 28 ++- .../domain/session/SessionReducer.kt | 48 +++- .../service/provision/ProxyAppBuildRunner.kt | 58 ++++- .../service/session/LiveSessionFactory.kt | 7 +- .../session/QuickBuildSessionManager.kt | 125 +++++++--- .../domain/session/SessionReducerTest.kt | 77 ++++-- .../provision/ProxyAppBuildRunnerEdgeTest.kt | 22 +- .../provision/ProxyAppBuildRunnerTest.kt | 53 ++-- .../session/QuickBuildSessionManagerTest.kt | 234 +++++++----------- 12 files changed, 425 insertions(+), 257 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt index 8510917021..a4bd71326d 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt @@ -98,6 +98,17 @@ data class ProxyAppInfo( val supportsComponentInfo: Boolean get() = schema >= COMPONENT_SCHEMA_VERSION + /** + * Proxy class of the activity that carries MAIN/LAUNCHER, or null when none does; a null + * makes the launcher fall back to the package's default launch intent, which also resolves + * an `` launcher. + * + * The restart deploy, the rebuild relaunch and the foreground switch all launch this, so + * the three cannot drift apart. + */ + val launcherProxyClass: String? + get() = components.firstOrNull { it.kind == ComponentKind.ACTIVITY && it.launcher }?.proxyClass + companion object { private val log = LoggerFactory.getLogger("QB-ProxyAppInfo") diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildMessage.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildMessage.kt index 0d7116cf45..349d108d65 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildMessage.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildMessage.kt @@ -74,6 +74,15 @@ sealed interface QuickBuildMessage { /** The proxy app rebuild failed with no more specific cause to report. */ data object RebuildFailed : QuickBuildMessage + /** + * Provisioning died on an unexpected error that carried no message of its own. + * + * The fallback for a throw whose `message` is null (a bare NPE or `check`): the exception + * class name is diagnostic, belongs in the error log, and would read as gibberish on a + * banner - so the user gets this named case and the log keeps the class and stack. + */ + data object ProvisioningFailedUnexpectedly : QuickBuildMessage + /** * App storage is too tight to hold the build's intermediates. Checked up front so this * fails in seconds rather than minutes into a build. 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 index 364bc21889..67cb573c06 100644 --- 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 @@ -410,8 +410,16 @@ sealed interface SessionEvent { * [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). + * + * @property userInitiated true when a user gesture (the menu item, the dialog) asked for the + * restart. Copied into [QuickBuildSessionState.Provisioning.userInitiated], so the fresh + * session brings the proxy app forward when it goes live. False for an automatic + * reprovision, such as a Build Variants switch re-syncing the project, where nobody asked + * to leave the editor. Defaults to true; automatic callers opt out explicitly. */ - data object SessionRestartAndReprovisionRequested : SessionEvent + data class SessionRestartAndReprovisionRequested( + val userInitiated: Boolean = true, + ) : SessionEvent } /** Side effects the session manager must run after a transition. */ 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 index 3f030e5dfb..5b115cb4bc 100644 --- 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 @@ -23,15 +23,19 @@ stateDiagram-v2 Idle --> Provisioning: QuickBuildTapped Idle --> Prebuilding: PrebuildRequested + Idle --> Idle: FileSaved (clears lastStartFailed) Prebuilding --> Prebuilding: QuickBuildTapped (queue the tap) + Prebuilding --> Prebuilding: FileSaved (clears lastStartFailed) Prebuilding --> Provisioning: PrebuildFinished (tap queued) Prebuilding --> Idle: PrebuildFinished (no tap) Prebuilding --> Idle: CancelRequested (tap queued) - Provisioning --> Ready: ProvisioningSucceeded + Provisioning --> Ready: ProvisioningSucceeded (SwitchToProxyApp if userInitiated) + Provisioning --> Provisioning: QuickBuildTapped (records the ask; userInitiated = true) Provisioning --> Idle: ProvisioningFailed Provisioning --> Idle: CancelRequested + Provisioning --> Invalidated: ProxyAppRebuildFailed (awaitingRetry) Provisioning --> Invalidated: ProxyAppRebuildInstallNotConfirmed Provisioning --> Invalidated: ProxyAppRebuildDeferred @@ -47,6 +51,9 @@ stateDiagram-v2 Building --> Ready: BuildFailed Building --> Ready: CancelRequested (not warming) Building --> Ready: WarmCompileFinished + Building --> Building: QuickBuildTapped (warming - TriggerLiveReload; real build - MarkBuildUserInitiated) + Building --> Building: ProxyAppCrashed (warming - carry as pendingCrash) + Building --> Building: ExternalBuildCompleted (RefreshBaseline) Building --> Invalidated: InvalidationDetected Building --> Degraded: DaemonDied @@ -58,24 +65,33 @@ stateDiagram-v2 Deployed --> Ready: ProxyAppCrashed (record failure) Deployed --> Deployed: ExternalBuildCompleted (RefreshBaseline) - Invalidated --> Provisioning: ProxyAppRebuildStarted - Invalidated --> Invalidated: QuickBuildTapped / HostForegrounded (RunProxyAppRebuild) + Invalidated --> Provisioning: ProxyAppRebuildStarted (carries the reason as rebaselineReason) + Invalidated --> Invalidated: QuickBuildTapped (awaiting retry - RunProxyAppRebuild + SwitchToProxyApp) + Invalidated --> Invalidated: HostForegrounded retry (RunProxyAppRebuild) + Invalidated --> Invalidated: InvalidationDetected (awaiting retry - re-park + RunProxyAppRebuild) Invalidated --> Building: BuildStarted (awaiting retry) Invalidated --> Deployed: BuildSucceeded (awaiting retry) Invalidated --> Ready: BuildFailed (awaiting retry) Invalidated --> Invalidated: DaemonDied (awaiting retry, RespawnDaemon) - Degraded --> Ready: DaemonRespawned + Degraded --> Ready: DaemonRespawned (not restartFailed) + Degraded --> Degraded: DaemonRespawned (restartFailed - the announced daemon already died) + Degraded --> Degraded: DaemonDied / DaemonRestartFailed (restartFailed = true, no auto-retry) Degraded --> Invalidated: InvalidationDetected Degraded --> Degraded: ExternalBuildCompleted (RefreshBaseline) - Degraded --> Degraded: QuickBuildTapped (SurfaceMessage + RespawnDaemon) + Degraded --> Degraded: QuickBuildTapped (restartFailed - SurfaceMessage + RespawnDaemon; else ack only) Degraded --> Building: BuildStarted Degraded --> Deployed: BuildSucceeded Degraded --> Ready: BuildFailed note right of Idle SessionRestartRequested from any - non-Idle state -> Idle (TeardownSession) + non-Idle state -> Idle (TeardownSession). + SessionRestartAndReprovisionRequested from any + state -> Provisioning (TeardownAndProvision; + StartProvisioning from Idle) with userInitiated + carried from the event - true for the menu and + dialog, false for a variant-switch reprovision. end note ``` 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 index ea68d9473f..24f7527d1f 100644 --- 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 @@ -32,7 +32,7 @@ class SessionReducer { // 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) { + if (event is SessionEvent.SessionRestartAndReprovisionRequested) { val effect = if (state is QuickBuildSessionState.Idle) { // Nothing to tear down, so this is an ordinary first provision. @@ -40,8 +40,11 @@ class SessionReducer { } else { SessionEffect.TeardownAndProvision } + // The flag rides through so only a restart the USER asked for brings the proxy + // app forward when the fresh session goes live; an automatic reprovision (a + // Build Variants switch) must leave them in the editor. return SessionTransition( - QuickBuildSessionState.Provisioning(userInitiated = true), + QuickBuildSessionState.Provisioning(userInitiated = event.userInitiated), listOf(effect), ) } @@ -178,6 +181,15 @@ class SessionReducer { ) } + is SessionEvent.QuickBuildTapped -> { + // The tap asks to see the app once the user's changes are in it. The build + // already in flight covers the building, so the tap needs no effect of its + // own; recording userInitiated is what makes ProvisioningSucceeded above + // switch to the proxy app. Without this a save-triggered rebaseline, which + // provisions with userInitiated = false, would never answer the tap. + SessionTransition(state.copy(userInitiated = true)) + } + 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 @@ -619,17 +631,27 @@ class SessionReducer { 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, - ), - ) + // Output line, since that pane is driven by status transitions. The message goes out + // in both arms so the tap is never silent. + if (state.restartFailed) { + // Nothing is scheduled any more, so the tap is the retry; clearing + // restartFailed puts the status back to "restarting". + SessionTransition( + state.copy(restartFailed = false), + listOf( + SessionEffect.SurfaceMessage(QuickBuildMessage.DaemonRestartRetrying), + SessionEffect.RespawnDaemon, + ), + ) + } else { + // The DaemonDied respawn is still in flight, and a respawn never bumps the + // daemon epoch - so a second RespawnDaemon here would RACE the first for + // the same daemon rather than be answered with Superseded. Ack only. + SessionTransition( + state, + listOf(SessionEffect.SurfaceMessage(QuickBuildMessage.DaemonRestartRetrying)), + ) + } } SessionEvent.BuildStarted -> { 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 index f00e1dea30..ea80b0692b 100644 --- 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 @@ -4,7 +4,6 @@ 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 @@ -130,7 +129,12 @@ internal class ProxyAppBuildRunner( throw e } catch (e: Throwable) { log.error("Provisioner threw instead of reporting an outcome", e) - ProvisionOutcome.Failure(QuickBuildMessage.Literal(e.message ?: e.javaClass.name)) + // Messageless throw: the class name is in the log line above; a banner + // showing "java.lang.IllegalStateException" would tell the user nothing. + ProvisionOutcome.Failure( + e.message?.let { QuickBuildMessage.Literal(it) } + ?: QuickBuildMessage.ProvisioningFailedUnexpectedly, + ) } if (superseded()) { @@ -207,7 +211,12 @@ internal class ProxyAppBuildRunner( daemonController.markIntentionalTransition() daemonController.shutdown() } - ProvisionResult.Failed(QuickBuildMessage.Literal(e.message ?: e.javaClass.name)) + // Class name and stack are in the log line above; see the provision() + // catch for why a messageless throw gets the named case. + ProvisionResult.Failed( + e.message?.let { QuickBuildMessage.Literal(it) } + ?: QuickBuildMessage.ProvisioningFailedUnexpectedly, + ) } } } @@ -265,6 +274,13 @@ internal class ProxyAppBuildRunner( * build. The manager moves the session's deployed generation to it. */ val baselineGeneration: Long, + /** + * True when a user ask was outstanding and the runner relaunched the reinstalled + * app for it (best-effort, like every foreground switch: a refused start is + * logged, not retried). The manager drops its deferred ask on this, so the + * landing does not launch the app a second time for the same tap. + */ + val answeredUserAsk: Boolean, ) : ProxyAppRebuildResult } @@ -283,11 +299,18 @@ internal class ProxyAppBuildRunner( * 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 + * @param userAskOutstanding whether the user has asked to see the proxy app (a Quick Build + * tap still waiting to be answered); read at relaunch time. Only then does a successful + * rebuild relaunch the reinstalled app, and it says so through + * [ProxyAppRebuildResult.Succeeded.answeredUserAsk]. A save-triggered rebuild must not + * pull the user out of the editor, so without an ask the app stays where it is and + * catches up over the deploy channel's reconnect when the user next opens it. * @return what happened; the daemon is left down for every result except a success */ suspend fun rebuildProxyApp( parkedRetry: Boolean, superseded: () -> Boolean, + userAskOutstanding: () -> 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 @@ -304,7 +327,11 @@ internal class ProxyAppBuildRunner( 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)) + // Class name and stack are in the log line above; see the provision() + // catch for why a messageless throw gets a named case. + ProxyAppRebuildOutcome.Failure( + e.message?.let { QuickBuildMessage.Literal(it) } ?: QuickBuildMessage.RebuildFailed, + ) } // 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. @@ -357,7 +384,17 @@ internal class ProxyAppBuildRunner( daemonController.markIntentionalTransition() when (val started = daemonController.start(outcome.layout, outcome.proxyApp)) { is DaemonReply.Ok -> { - val toRunningMillis = relaunchRebuiltProxyApp(outcome.proxyApp, startedAtNanos) + // Only a rebuild the user is waiting on brings the reinstalled app + // forward; a background rebaseline leaves it where it is and lets the + // reconnect catch-up bring it in sync when the user opens it themselves. + val askOutstanding = userAskOutstanding() + val toRunningMillis = + if (askOutstanding) { + relaunchRebuiltProxyApp(outcome.proxyApp, startedAtNanos) + } else { + log.info("Proxy app rebuilt with no user ask outstanding; staying in the background") + null + } bookRebuildMetric( relaunchOk = toRunningMillis != null, toRunningMillis = toRunningMillis, @@ -366,6 +403,7 @@ internal class ProxyAppBuildRunner( outcome.proxyApp, outcome.layout, outcome.baselineGeneration, + answeredUserAsk = askOutstanding, ) } @@ -386,9 +424,8 @@ internal class ProxyAppBuildRunner( /** * 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 + * The same machinery as the restart deploy's relaunch, aimed at the shared launch + * target ([ProxyAppInfo.launcherProxyClass]). 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. @@ -404,10 +441,7 @@ internal class ProxyAppBuildRunner( proxyApp: ProxyAppInfo, rebuildStartedAtNanos: Long, ): Long? { - val launcherActivity = - proxyApp.components - .firstOrNull { it.kind == ComponentKind.ACTIVITY && it.launcher } - ?.proxyClass + val launcherActivity = proxyApp.launcherProxyClass if (!launcher.launch(proxyApp.proxyAppPackage, launcherActivity)) { log.warn( "Proxy app {} could not be relaunched after the rebuild; open it manually", 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 index 0597884354..235c390a69 100644 --- 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 @@ -11,7 +11,6 @@ import org.appdevforall.cotg.quickbuild.domain.annotations.AnnotationImpactAnaly 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 @@ -150,10 +149,8 @@ internal class LiveSessionFactory( componentInfoAvailable = proxyApp.supportsComponentInfo, ), proxyAppPackage = proxyApp.proxyAppPackage, - launcherActivity = - proxyApp.components - .firstOrNull { it.kind == ComponentKind.ACTIVITY && it.launcher } - ?.proxyClass, + // The shared launch-target rule; see [ProxyAppInfo.launcherProxyClass]. + launcherActivity = proxyApp.launcherProxyClass, launcher = launcher, clock = nowMillis, metrics = metrics, 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 4c95e74350..2fa77d4c8a 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 @@ -29,7 +29,6 @@ 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 @@ -306,11 +305,22 @@ class QuickBuildSessionManager( * 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]. + * [settleDeferredForegroundAsk] for when it is answered, expired or dropped. A rebaseline + * whose own relaunch answered it clears it first (see [rebuildProxyApp]), so one tap is + * one launch. Only touched on [dispatcher]. */ private var foregroundAskDeferredAtMillis: Long? = null + /** + * Whether the build the deferred ask is waiting on is a rebaseline (a proxy app rebuild), + * captured when the ask is first deferred. A rebaseline ask is exempt from the + * [DEFERRED_FOREGROUND_ASK_MAX_AGE_MILLIS] expiry: the user clicked Quick Build, so the + * switch happens once enough building has happened for their changes to be in the app - + * however long the Gradle rebuild takes on a phone. Meaningless while + * [foregroundAskDeferredAtMillis] is null; cleared with it. Only touched on [dispatcher]. + */ + private var foregroundAskAwaitsRebaseline = false + /** Owns the daemon lifecycle protocol; see [QuickBuildDaemonController]. */ private val daemonController = QuickBuildDaemonController(daemon, scratch, paths) @@ -505,7 +515,9 @@ class QuickBuildSessionManager( provisioned, selectedVariant, ) - dispatch(SessionEvent.SessionRestartAndReprovisionRequested) + // Not user-initiated: the user changed a build variant, not asked for the + // proxy app - the fresh session comes up in the background. + dispatch(SessionEvent.SessionRestartAndReprovisionRequested(userInitiated = false)) } else { dispatch(SessionEvent.PrebuildRequested) } @@ -544,7 +556,9 @@ class QuickBuildSessionManager( * already promises. */ fun restartSessionAndReprovision() { - scope.launch { dispatch(SessionEvent.SessionRestartAndReprovisionRequested) } + // Explicitly user-initiated: both callers are gestures (the long-press menu item and + // the dialog button), so the rebuilt session is brought forward when it lands. + scope.launch { dispatch(SessionEvent.SessionRestartAndReprovisionRequested(userInitiated = true)) } } /** @@ -840,24 +854,30 @@ class QuickBuildSessionManager( // 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. + // backgrounded app. The ask is answered when the rebaseline lands - however long + // that takes - and dropped if it does not land. 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() + if (foregroundAskDeferredAtMillis == null) { + foregroundAskDeferredAtMillis = nowMillis() + // Captured once, with the stamp: is the build being waited on a rebaseline? + // (A parked rebuild's retry shows as Invalidated with the retry under way; a + // running one as Provisioning with a rebaseline reason.) + foregroundAskAwaitsRebaseline = + when (val state = _state.value) { + is QuickBuildSessionState.Invalidated -> !state.awaitingRetry + is QuickBuildSessionState.Provisioning -> state.rebaselineReason != null + else -> false + } + } 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)) { + foregroundAskAwaitsRebaseline = false + // Same target every launch path uses; see [ProxyAppInfo.launcherProxyClass]. + if (!launcher.launch(session.proxyApp.proxyAppPackage, session.proxyApp.launcherProxyClass)) { log.warn("Could not bring the proxy app {} to the foreground", session.proxyApp.proxyAppPackage) } } @@ -884,11 +904,14 @@ class QuickBuildSessionManager( * 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. + * done" means - unless the rebaseline's own relaunch already answered it, in which case + * [rebuildProxyApp] cleared the ask before landing and there is nothing left to do here. + * A rebaseline ask is answered however old it is - the user clicked Quick Build, + * so the switch happens once their changes are in the app, and a Gradle rebuild on a phone + * routinely outlives any reasonable bound. Only a non-rebaseline ask still expires past + * [DEFERRED_FOREGROUND_ASK_MAX_AGE_MILLIS]. 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. */ @@ -897,13 +920,14 @@ class QuickBuildSessionManager( when { state is QuickBuildSessionState.Ready || state is QuickBuildSessionState.Deployed -> { val ageMillis = nowMillis() - askedAtMillis - if (ageMillis > DEFERRED_FOREGROUND_ASK_MAX_AGE_MILLIS) { + if (!foregroundAskAwaitsRebaseline && 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 + foregroundAskAwaitsRebaseline = false return } // switchToProxyApp clears the ask itself, and re-checks the guard - a @@ -917,6 +941,7 @@ class QuickBuildSessionManager( (state is QuickBuildSessionState.Invalidated && state.awaitingRetry) -> { log.info("Quick Build's deferred proxy app switch dropped: the full build did not land") foregroundAskDeferredAtMillis = null + foregroundAskAwaitsRebaseline = false } else -> { @@ -999,7 +1024,14 @@ class QuickBuildSessionManager( // 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))) + // Messageless throw: the class name lives in the log line above, not on + // the banner. + dispatch( + SessionEvent.ProvisioningFailed( + e.message?.let { QuickBuildMessage.Literal(it) } + ?: QuickBuildMessage.ProvisioningFailedUnexpectedly, + ), + ) } } } @@ -1135,6 +1167,16 @@ class QuickBuildSessionManager( buildRunner.rebuildProxyApp( parkedRetry = installRetryPark != null, superseded = { startEpoch != sessionEpoch }, + // The user asked to see the app: either a tap deferred until this build + // lands (foregroundAskDeferredAtMillis) or a tap recorded onto the + // rebaseline itself (Provisioning.userInitiated). Anything else - a save, + // a foreground return - is not an ask, and the rebuilt app stays in the + // background. A relaunch here answers the deferred ask; the Succeeded + // branch below clears it so the landing does not launch again. + userAskOutstanding = { + foregroundAskDeferredAtMillis != null || + (_state.value as? QuickBuildSessionState.Provisioning)?.userInitiated == true + }, ) when (result) { @@ -1155,14 +1197,29 @@ class QuickBuildSessionManager( 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. + // A first rebuild losing the Gradle slot is a routine collision (the + // gradle edit that invalidated the session usually also starts CoGo's + // own project sync), and nothing failed: the session and the running + // proxy app are both fine. So park for retry (the next save, tap or + // foreground return) instead of dropping to Idle with a failure banner. + // onProxyAppRebuildFailed returns the held batch to pending, so the + // retry re-reports the invalidation. + log.info("Gradle slot busy; parking the proxy app rebuild for retry") session.orchestrator.onProxyAppRebuildFailed() - dispatch(SessionEvent.ProvisioningFailed(QuickBuildMessage.RebuildFailed)) + rebuildPark?.let { park -> + dispatch(SessionEvent.ProxyAppRebuildFailed(park.reason, park.deployedGeneration)) + } ?: dispatch(SessionEvent.ProvisioningFailed(QuickBuildMessage.RebuildFailed)) } } is ProxyAppBuildRunner.ProxyAppRebuildResult.Succeeded -> { + if (result.answeredUserAsk) { + // The runner's relaunch already brought the app forward for this ask. + // Cleared before the landing dispatches, so settleDeferredForegroundAsk + // does not launch it a second time for the same tap. + foregroundAskDeferredAtMillis = null + foregroundAskAwaitsRebaseline = false + } try { // Both delegates are built before adoptBaseline moves anything: // executorFor can throw on a null entryActivity, which the rebuild @@ -1197,7 +1254,13 @@ class QuickBuildSessionManager( } 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))) + // Messageless throw: the class name is in the log line above, where it + // helps; on a banner it would read as gibberish. + dispatch( + SessionEvent.ProvisioningFailed( + e.message?.let { QuickBuildMessage.Literal(it) } ?: QuickBuildMessage.RebuildFailed, + ), + ) } } @@ -1442,10 +1505,10 @@ class QuickBuildSessionManager( 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. + * Oldest a NON-rebaseline deferred foreground ask may be and still be answered when + * the build lands. Rebaseline asks are exempt (see [foregroundAskAwaitsRebaseline]): + * a Gradle rebuild on a phone routinely takes minutes, so an age bound there would + * expire every real ask. */ private const val DEFERRED_FOREGROUND_ASK_MAX_AGE_MILLIS = 10_000L 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 index 96521c95c1..70bf590aaa 100644 --- 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 @@ -121,12 +121,22 @@ class SessionReducerTest { } @Test - fun `provisioning ignores a QuickBuildTapped event`() { - val transition = - reducer.reduce(QuickBuildSessionState.Provisioning(), SessionEvent.QuickBuildTapped()) - - assertThat(transition.state).isEqualTo(QuickBuildSessionState.Provisioning()) + fun `a tap during provisioning records the ask instead of dropping it`() { + // The user clicked Quick Build, so the switch to the proxy app happens once enough + // building has happened - here, when the full build in flight lands. The case that + // matters is a save-triggered rebaseline: it provisions with userInitiated = false, + // so dropping the tap left that ask permanently unanswered. + val rebaselining = + QuickBuildSessionState.Provisioning(rebaselineReason = InvalidationReason.GRADLE_CONFIG_CHANGED) + val transition = reducer.reduce(rebaselining, SessionEvent.QuickBuildTapped()) + + // No effect on purpose: the build in flight already covers the tap's build half. + assertThat(transition.state).isEqualTo(rebaselining.copy(userInitiated = true)) assertThat(transition.effects).isEmpty() + + // The recorded ask is answered when that build lands. + val landed = reducer.reduce(transition.state, SessionEvent.ProvisioningSucceeded(3)) + assertThat(landed.effects).contains(SessionEffect.SwitchToProxyApp) } @Test @@ -1025,7 +1035,7 @@ class SessionReducerTest { val transition = reducer.reduce( QuickBuildSessionState.Ready(3), - SessionEvent.SessionRestartAndReprovisionRequested, + SessionEvent.SessionRestartAndReprovisionRequested(), ) assertThat(transition.state).isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) @@ -1037,7 +1047,7 @@ class SessionReducerTest { val transition = reducer.reduce( QuickBuildSessionState.Idle(), - SessionEvent.SessionRestartAndReprovisionRequested, + SessionEvent.SessionRestartAndReprovisionRequested(), ) assertThat(transition.state).isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) @@ -1049,7 +1059,7 @@ class SessionReducerTest { val transition = reducer.reduce( QuickBuildSessionState.Deployed(4, 900), - SessionEvent.SessionRestartAndReprovisionRequested, + SessionEvent.SessionRestartAndReprovisionRequested(), ) assertThat(transition.state).isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) @@ -1061,7 +1071,7 @@ class SessionReducerTest { val transition = reducer.reduce( QuickBuildSessionState.Building(1), - SessionEvent.SessionRestartAndReprovisionRequested, + SessionEvent.SessionRestartAndReprovisionRequested(), ) assertThat(transition.state).isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) @@ -1073,7 +1083,7 @@ class SessionReducerTest { val transition = reducer.reduce( QuickBuildSessionState.Degraded(1), - SessionEvent.SessionRestartAndReprovisionRequested, + SessionEvent.SessionRestartAndReprovisionRequested(), ) assertThat(transition.state).isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) @@ -1088,7 +1098,7 @@ class SessionReducerTest { val transition = reducer.reduce( QuickBuildSessionState.Invalidated(InvalidationReason.MANIFEST_CHANGED, 2), - SessionEvent.SessionRestartAndReprovisionRequested, + SessionEvent.SessionRestartAndReprovisionRequested(), ) assertThat(transition.state).isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true)) @@ -1096,6 +1106,25 @@ class SessionReducerTest { assertThat(transition.effects).isEqualTo(listOf(SessionEffect.TeardownAndProvision)) } + @Test + fun `an automatic reprovision - a variant switch - goes live without stealing the screen`() { + // Nobody tapped anything: the restart was CoGo reacting to a Build Variants change. + // The fresh session must come up in the background, so the flag from the event rides + // into Provisioning instead of being assumed true. + val transition = + reducer.reduce( + QuickBuildSessionState.Ready(2), + SessionEvent.SessionRestartAndReprovisionRequested(userInitiated = false), + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = false)) + assertThat(transition.effects).isEqualTo(listOf(SessionEffect.TeardownAndProvision)) + + // And the fresh session's landing leaves the user in the editor. + val landed = reducer.reduce(transition.state, SessionEvent.ProvisioningSucceeded(0)) + assertThat(landed.effects).doesNotContain(SessionEffect.SwitchToProxyApp) + } + // 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. @@ -1231,13 +1260,31 @@ class SessionReducerTest { // 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. + fun `a tap while the respawn is in flight acks without racing a second respawn`() { + // Degraded with restartFailed = false means the DaemonDied respawn is still running, + // and a respawn never bumps the daemon epoch - so a second RespawnDaemon here would + // race the first for the same daemon rather than be answered with Superseded. The tap + // is still acknowledged, or it would look ignored (the in-flight respawn reports + // nothing when it lands). 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))) + } + + @Test + fun `a tap after the respawn gave up retries it`() { + // restartFailed = true means nothing is scheduled any more - here the tap IS the + // retry, so RespawnDaemon rides along and the flag resets so the status reads + // "restarting" again. + val transition = + reducer.reduce( + QuickBuildSessionState.Degraded(3, restartFailed = true), + SessionEvent.QuickBuildTapped(), + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Degraded(3, restartFailed = false)) assertThat(transition.effects) .isEqualTo( listOf( 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 index 30df6c6bb6..6defd91d66 100644 --- 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 @@ -103,8 +103,10 @@ class ProxyAppBuildRunnerEdgeTest { ) @Test - fun `a message-less provisioner throw is reported by exception class name`() = + fun `a message-less provisioner throw surfaces a named failure, not a class name`() = runTest { + // A bare NPE or `check` carries no message; the class name is diagnostic and + // belongs in the error log, not on a banner. provisioner.provisionOutcome = { throw IllegalStateException() } val result = runner().provision(superseded = { false }) @@ -112,7 +114,7 @@ class ProxyAppBuildRunnerEdgeTest { assertThat(result) .isEqualTo( ProxyAppBuildRunner.ProvisionResult.Failed( - QuickBuildMessage.Literal(IllegalStateException::class.java.name), + QuickBuildMessage.ProvisioningFailedUnexpectedly, ), ) } @@ -185,7 +187,7 @@ class ProxyAppBuildRunnerEdgeTest { ProxyAppRebuildOutcome.Success(proxyApp(), QuickBuildProjectLayout(projectRoot)) } - val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { true }) + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { true }, userAskOutstanding = { true }) assertThat(result).isEqualTo(ProxyAppBuildRunner.ProxyAppRebuildResult.Superseded) // The superseded rebuild must NOT restart a daemon for a dead session. @@ -193,18 +195,14 @@ class ProxyAppBuildRunnerEdgeTest { } @Test - fun `a message-less rebuild throw is reported by exception class name`() = + fun `a message-less rebuild throw surfaces the named rebuild failure, not a class name`() = runTest { provisioner.rebuildOutcome = { throw IllegalStateException() } - val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }, userAskOutstanding = { true }) assertThat(result) - .isEqualTo( - ProxyAppBuildRunner.ProxyAppRebuildResult.Failed( - QuickBuildMessage.Literal(IllegalStateException::class.java.name), - ), - ) + .isEqualTo(ProxyAppBuildRunner.ProxyAppRebuildResult.Failed(QuickBuildMessage.RebuildFailed)) } @Test @@ -214,7 +212,7 @@ class ProxyAppBuildRunnerEdgeTest { ProxyAppRebuildOutcome.InstallNotConfirmed(QuickBuildMessage.Literal("tap install")) } - val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }, userAskOutstanding = { true }) assertThat(result) .isEqualTo( @@ -232,7 +230,7 @@ class ProxyAppBuildRunnerEdgeTest { } daemon.startReply = DaemonReply.BuildFailed(emptyList()) - val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }, userAskOutstanding = { true }) assertThat(result) .isEqualTo( 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 index 1a00ff80b0..a7884c2ebe 100644 --- 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 @@ -161,7 +161,7 @@ class ProxyAppBuildRunnerTest { 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 }) + val result = runner().rebuildProxyApp(parkedRetry = true, superseded = { false }, userAskOutstanding = { true }) assertThat(result).isEqualTo(ProxyAppBuildRunner.ProxyAppRebuildResult.BuildSlotBusy) assertThat(metrics.rebuilds).isEmpty() } @@ -170,7 +170,7 @@ class ProxyAppBuildRunnerTest { 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 }) + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }, userAskOutstanding = { true }) assertThat(result).isEqualTo(ProxyAppBuildRunner.ProxyAppRebuildResult.BuildSlotBusy) assertThat(metrics.rebuilds).containsExactly(false) } @@ -184,7 +184,7 @@ class ProxyAppBuildRunnerTest { ProxyAppRebuildOutcome.Success(proxyApp(newRoot), QuickBuildProjectLayout(newRoot)) } - val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }, userAskOutstanding = { true }) assertThat(result) .isInstanceOf(ProxyAppBuildRunner.ProxyAppRebuildResult.Succeeded::class.java) @@ -219,10 +219,12 @@ class ProxyAppBuildRunnerTest { } deploy.reconnectGeneration = { 7L } - val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }, userAskOutstanding = { true }) assertThat(result) .isInstanceOf(ProxyAppBuildRunner.ProxyAppRebuildResult.Succeeded::class.java) + // The manager reads this to drop its deferred ask: one tap, one launch. + assertThat((result as ProxyAppBuildRunner.ProxyAppRebuildResult.Succeeded).answeredUserAsk).isTrue() // Same (package, launcherActivity) shape the restart deploy launches with. assertThat(launches).containsExactly("com.example.quickbuild" to "com.example.QbMain") assertThat(metrics.rebuilds).containsExactly(true) @@ -240,11 +242,32 @@ class ProxyAppBuildRunnerTest { } deploy.reconnectGeneration = { 7L } - runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + runner().rebuildProxyApp(parkedRetry = false, superseded = { false }, userAskOutstanding = { true }) assertThat(launches).containsExactly("com.example.quickbuild" to null) } + @Test + fun `a successful rebuild with no user ask outstanding does not relaunch the app`() = + runTest { + // The user did not click Quick Build - this rebaseline came from a save - so the + // reinstalled app stays in the background. The deploy channel's reconnect + // catch-up keeps it current for whenever the user opens it themselves. + provisioner.rebuildOutcome = { + ProxyAppRebuildOutcome.Success(proxyApp(), QuickBuildProjectLayout(projectRoot)) + } + + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }, userAskOutstanding = { false }) + + assertThat(result) + .isInstanceOf(ProxyAppBuildRunner.ProxyAppRebuildResult.Succeeded::class.java) + assertThat((result as ProxyAppBuildRunner.ProxyAppRebuildResult.Succeeded).answeredUserAsk).isFalse() + assertThat(launches).isEmpty() + // The rebuild still books as a success; only the relaunch fields stay empty. + assertThat(metrics.rebuilds).containsExactly(true) + assertThat(metrics.relaunches).containsExactly(false to null) + } + @Test fun `a failed rebuild never relaunches and books relaunchOk false`() = runTest { @@ -252,7 +275,7 @@ class ProxyAppBuildRunnerTest { ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal("bad build.gradle")) } - val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }, userAskOutstanding = { true }) assertThat(result) .isEqualTo(ProxyAppBuildRunner.ProxyAppRebuildResult.Failed(QuickBuildMessage.Literal("bad build.gradle"))) @@ -268,7 +291,7 @@ class ProxyAppBuildRunnerTest { } daemon.startReply = DaemonReply.Failed("no memory") - runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + runner().rebuildProxyApp(parkedRetry = false, superseded = { false }, userAskOutstanding = { true }) assertThat(launches).isEmpty() // The Gradle build itself succeeded, so isSuccess stays true as before... @@ -285,7 +308,7 @@ class ProxyAppBuildRunnerTest { } launchResult = false - val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }, userAskOutstanding = { true }) // The relaunch is best-effort: the baseline and daemon are fine, so the // rebuild result must not fail on it. @@ -305,7 +328,7 @@ class ProxyAppBuildRunnerTest { val reconnects = ArrayDeque(listOf(null, 7L)) deploy.reconnectGeneration = { reconnects.removeFirst() } - val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }, userAskOutstanding = { true }) assertThat(result) .isInstanceOf(ProxyAppBuildRunner.ProxyAppRebuildResult.Succeeded::class.java) @@ -323,7 +346,7 @@ class ProxyAppBuildRunnerTest { } deploy.reconnectGeneration = { null } - val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }, userAskOutstanding = { true }) // Two starts were issued (the swallowed-start retry), then it gave up. assertThat(launches).hasSize(2) @@ -341,7 +364,7 @@ class ProxyAppBuildRunnerTest { ProxyAppRebuildOutcome.InstallNotConfirmed(QuickBuildMessage.Literal("tap install")) } - runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + runner().rebuildProxyApp(parkedRetry = false, superseded = { false }, userAskOutstanding = { true }) assertThat(launches).isEmpty() assertThat(metrics.relaunches).containsExactly(false to null) @@ -354,7 +377,7 @@ class ProxyAppBuildRunnerTest { ProxyAppRebuildOutcome.Success(proxyApp(), QuickBuildProjectLayout(projectRoot)) } - val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { true }) + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { true }, userAskOutstanding = { true }) assertThat(result).isEqualTo(ProxyAppBuildRunner.ProxyAppRebuildResult.Superseded) assertThat(launches).isEmpty() @@ -369,7 +392,7 @@ class ProxyAppBuildRunnerTest { ProxyAppRebuildOutcome.Success(proxyApp(), QuickBuildProjectLayout(projectRoot)) } daemon.startReply = DaemonReply.Failed("no memory") - val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }) + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }, userAskOutstanding = { true }) assertThat(result) .isEqualTo(ProxyAppBuildRunner.ProxyAppRebuildResult.DaemonRestartFailed("no memory")) } @@ -378,7 +401,7 @@ class ProxyAppBuildRunnerTest { 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 }) + val result = runner().rebuildProxyApp(parkedRetry = false, superseded = { false }, userAskOutstanding = { true }) assertThat(result) .isEqualTo(ProxyAppBuildRunner.ProxyAppRebuildResult.Failed(QuickBuildMessage.Literal("gradle exploded"))) // A real attempt that died still books a failed rebuild. @@ -403,6 +426,8 @@ class ProxyAppBuildRunnerTest { .isEqualTo(ProxyAppBuildRunner.ProvisionResult.Failed(QuickBuildMessage.Literal("provision exploded"))) } + // Message-less throw fallbacks live in [ProxyAppBuildRunnerEdgeTest]. + @Test fun `a session assembly throw after the daemon started unwinds the session and daemon and becomes Failed`() = runTest { 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 7c1803fae6..d865333274 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 @@ -63,8 +63,9 @@ class QuickBuildSessionManagerTest { 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). + // that comes back at the baseline stamp, so a rebaseline that DOES relaunch (one + // with a user ask outstanding) books exactly ONE launch (no swallowed-start + // retry). A rebaseline nobody asked for relaunches nothing. reconnectGeneration = { 0L } } private val connections = ProxyAppConnections() @@ -89,6 +90,9 @@ class QuickBuildSessionManagerTest { private val metricsEvents = mutableListOf() private var metricsThrow = false + /** relaunchOk of each booked proxy app rebuild, in order. */ + private val rebuildRelaunches = mutableListOf() + private val recordingMetrics = object : QuickBuildMetricsSink { override fun onSessionStarted() { @@ -124,6 +128,7 @@ class QuickBuildSessionManagerTest { toRunningMillis: Long?, ) { record { "proxyAppRebuild:$isSuccess" } + rebuildRelaunches += relaunchOk } private fun record(event: () -> String) { @@ -1965,11 +1970,13 @@ class QuickBuildSessionManagerTest { } @Test - fun `a first proxy app rebuild that cannot get the Gradle slot is reported, not parked`() = + fun `a first proxy app rebuild that cannot get the Gradle slot parks for retry instead of dying`() = 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. + // The collision is routine, not exceptional: the gradle edit that invalidated the + // session is often the same edit that makes CoGo start its own project sync, which + // holds the device's single Gradle slot. The session and the running proxy app are + // both fine, so dropping to Idle turned contention into a dead session; parking + // keeps the next save, tap or foreground return as the retry. proxyAppRebuildOutcome = { ProxyAppRebuildOutcome.BuildSlotBusy } val manager = createManager() manager.onQuickBuildTapped() @@ -1978,12 +1985,27 @@ class QuickBuildSessionManagerTest { 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(manager.state.value) + .isEqualTo( + QuickBuildSessionState.Invalidated( + InvalidationReason.GRADLE_CONFIG_CHANGED, + 0, + awaitingRetry = true, + ), + ) + // No failure banner: nothing failed, and the park's own status names the wait. + assertThat(userMessages).doesNotContain(QuickBuildMessage.RebuildFailed) + // The lost-slot attempt still books like a failed rebuild - only a slot-busy retry + // FROM the park skips the metrics sink. assertThat(metricsEvents.filter { it.startsWith("proxyAppRebuild:") }) .containsExactly("proxyAppRebuild:false") + + // And the park recovers the same way a failed rebuild does: the next save retries. + proxyAppRebuildOutcome = { defaultProxyAppRebuildSuccess() } + manager.save(gradleFile) + advanceUntilIdle() + assertThat(proxyAppRebuildCount).isEqualTo(2) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) } @Test @@ -2520,6 +2542,7 @@ class QuickBuildSessionManagerTest { val manager = createManager() manager.onQuickBuildTapped() advanceUntilIdle() + val launchesBefore = launches.size provisionOutcome = { defaultProvisionOutcome("fullDebug") } manager.onProjectSynced("fullDebug") @@ -2529,6 +2552,9 @@ class QuickBuildSessionManagerTest { assertThat(daemon.shutdownCount).isEqualTo(1) assertThat(daemon.startConfigs).hasSize(2) assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + // Nobody tapped anything: the reprovision is CoGo reacting to the variant change, + // so the fresh session comes up in the background instead of stealing the screen. + assertThat(launches).hasSize(launchesBefore) } @Test @@ -3388,10 +3414,13 @@ class QuickBuildSessionManagerTest { 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. + // (ADFA-4128: the rebaseline shares the restart deploy's launch path), and that + // relaunch IS the answer to the deferred ask - the landing must not launch again. assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) - assertThat(launches).hasSize(launchesBefore + 2) + assertThat(launches).hasSize(launchesBefore + 1) + // The one launch came from the rebuild's relaunch, not from a settle after a + // skipped relaunch: the rebuild booked itself as relaunched. + assertThat(rebuildRelaunches.last()).isTrue() } @Test @@ -3426,11 +3455,13 @@ class QuickBuildSessionManagerTest { } @Test - fun `a deferred foreground ask that has gone stale expires instead of yanking the user out of the editor`() = + fun `a rebaseline ask is answered however long the rebuild took - the tap said where the user wants to be`() = 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. + // The user clicked Quick Build, so the switch happens once their changes are in + // the app - for a tap that started a rebaseline, that is the rebuild's landing, + // however slow the Gradle build was. A rebaseline ask is therefore EXEMPT from + // the 10 s age bound, which was sized for build-queue asks, not for multi-minute + // Gradle rebuilds. var failProxyAppRebuild = true proxyAppRebuildOutcome = { if (failProxyAppRebuild) { @@ -3456,14 +3487,12 @@ class QuickBuildSessionManagerTest { advanceUntilIdle() assertThat(launches).hasSize(launchesBefore) - // The rebaseline grinds on well past the point where the ask still means anything. + // The rebaseline grinds on far past the old bound. 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. + // The rebaseline's own relaunch answers the ask; the landing does not launch again. assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) assertThat(launches).hasSize(launchesBefore + 1) } @@ -3471,8 +3500,8 @@ class QuickBuildSessionManagerTest { @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. + // The short-rebuild half of the exemption: a quick rebaseline owes the user the + // switch they asked for just as much as a slow one. var failProxyAppRebuild = true proxyAppRebuildOutcome = { if (failProxyAppRebuild) { @@ -3500,17 +3529,16 @@ class QuickBuildSessionManagerTest { rebGate.complete(Unit) advanceUntilIdle() - // The rebaseline's own relaunch plus the answered ask. + // The rebaseline's own relaunch answers the ask; the landing does not launch again. assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) - assertThat(launches).hasSize(launchesBefore + 2) + assertThat(launches).hasSize(launchesBefore + 1) } @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. + // The former boundary, kept as a regression check: under the rebaseline exemption + // every age is answered, so this must stay green if an age check is reintroduced. var failProxyAppRebuild = true proxyAppRebuildOutcome = { if (failProxyAppRebuild) { @@ -3538,16 +3566,16 @@ class QuickBuildSessionManagerTest { rebGate.complete(Unit) advanceUntilIdle() - // The rebaseline's own relaunch plus the answered ask. + // The rebaseline's own relaunch answers the ask; the landing does not launch again. assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) - assertThat(launches).hasSize(launchesBefore + 2) + assertThat(launches).hasSize(launchesBefore + 1) } @Test - fun `a deferred foreground ask one millisecond past the age bound expires`() = + fun `a deferred foreground ask just past the age bound is answered too - the bound does not apply to rebaselines`() = runTest { - // The expiry partner of the exact-bound test: together they pin the constant at - // 10 s in both directions. + // The first millisecond the former expiry would have fired; checks the exemption + // from the other side of the old bound. var failProxyAppRebuild = true proxyAppRebuildOutcome = { if (failProxyAppRebuild) { @@ -3575,19 +3603,18 @@ class QuickBuildSessionManagerTest { rebGate.complete(Unit) advanceUntilIdle() - // Only the rebaseline's own relaunch; the expired ask adds no second switch. + // The rebaseline's own relaunch answers the ask; the landing does not launch again. 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`() = + fun `chained full builds settle the deferred ask exactly once`() = 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. + // before the chained invalidation can dispatch, so the ask is settled ONCE there - + // and never again by the chained build's own landing. var failProxyAppRebuild = true proxyAppRebuildOutcome = { if (failProxyAppRebuild) { @@ -3624,131 +3651,42 @@ class QuickBuildSessionManagerTest { 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) + // The first landing's relaunch answers the 6-second-old ask - one launch - before + // the chained rebuild takes the session back to Provisioning. + assertThat(launches).hasSize(launchesBefore + 1) 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. + // The chained landing has no ask outstanding - the tap was answered at the first + // one - so the reinstalled app stays in the background and the tap is not + // answered 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`() = + fun `a save-triggered rebaseline stays in the background - no relaunch without a user ask`() = 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 }) + // The user did NOT click Quick Build - the rebaseline was CoGo reacting to a + // gradle-file save - so the reinstalled app must not be brought forward. The + // deploy channel reconnects in the background and its catch-up keeps the app + // current for whenever the user opens it themselves. + val manager = createManager() 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. + // The rebaseline landed and the session is live on the new baseline... assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) - assertThat(launches).hasSize(launchesBefore + 3) + assertThat(proxyAppRebuildCount).isEqualTo(1) + // ...without yanking the user out of the editor. + assertThat(launches).hasSize(launchesBefore) } @Test @@ -4658,10 +4596,11 @@ class QuickBuildSessionManagerTest { } @Test - fun `a messageless throw during the rebuild's re-baseline surfaces the exception class name`() = + fun `a messageless throw during the rebuild's re-baseline surfaces a named failure, not a 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. + // A bare `checkNotNull` / NPE carries no message. The class name is diagnostic - + // it reads as gibberish on a banner - so the user gets the named rebuild failure + // and the error log keeps the class and stack. val manager = createManager() manager.onQuickBuildTapped() advanceUntilIdle() @@ -4670,8 +4609,7 @@ class QuickBuildSessionManagerTest { manager.save(gradleFile) advanceUntilIdle() - assertThat(userMessages) - .contains(QuickBuildMessage.Literal("java.lang.IllegalStateException")) + assertThat(userMessages).contains(QuickBuildMessage.RebuildFailed) assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle(lastStartFailed = true)) } From faf18a41848e0371c87b121d9065a09c57c025d8 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 2 Sep 2026 17:39:43 -0700 Subject: [PATCH 5/9] ADFA-4128: 0902 review round on quickbuild:core orchestration Akash's 2 September round on the session state machine. - A tap landing while the rebaseline is already running no longer launches the proxy app twice. The rebuild relaunches the reinstalled app itself for an outstanding ask; ProvisioningSucceeded now says so, and the reducer skips the switch it would otherwise emit for the same tap. Pinned by a test that fails without the guard. https://github.com/appdevforall/CodeOnTheGo/pull/1720#discussion_r3916736341 - answeredUserAsk is true only when the relaunch actually succeeded, so a refused start leaves the ask outstanding for the landing to answer instead of dropping it. https://github.com/appdevforall/CodeOnTheGo/pull/1720#discussion_r3916736351 - The daemon death listener reads the epoch on the reaper thread and drops a death that the session's own intentional transition caused. This does not close the duplicate-DaemonDied finding it was filed under; see below. https://github.com/appdevforall/CodeOnTheGo/pull/1720#discussion_r3916736330 - A build that deployed nothing reports the generation the app is running, not the newest one allocated. A failed deploy leaves the allocator ahead of the app, and reporting it advanced the session's deploy tally past a generation the app never ran, forcing a catch-up build on every reconnect. https://github.com/appdevforall/CodeOnTheGo/pull/1720#discussion_r3916736357 - The tap's history write is skipped once the project has recorded a Quick Build, so a blocking preference commit no longer runs on the single-threaded session dispatcher on every tap. That gives hasUsedQuickBuild its only caller, and the constructor doc no longer claims the prebuild gates on it. https://github.com/appdevforall/CodeOnTheGo/pull/1720#discussion_r3916736363 https://github.com/appdevforall/CodeOnTheGo/pull/1720#discussion_r3916736384 - Both remaining messageless-throwable sites fall back to named copy rather than the exception class name, which reaches the status surface verbatim. https://github.com/appdevforall/CodeOnTheGo/pull/1720#discussion_r3916751446 https://github.com/appdevforall/CodeOnTheGo/pull/1720#discussion_r3916736375 Not fixed here: the duplicate DaemonDied itself. One death is reported twice - by the death listener and by the build that was riding the daemon - and telling the second report from a fresh death of the respawned daemon needs a daemon instance identity on the event. The only place that identity exists is the daemon client, which belongs to the PR below this one, so the fix wants its own change rather than a cross-PR edit in a review pass. A flag for "a respawn is in flight" was tried and rejected: it also swallows the death of a daemon that dies inside its own start, which an existing test pins. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../domain/reload/LiveReloadExecutor.kt | 9 ++++ .../domain/reload/LiveReloadOrchestrator.kt | 2 +- .../domain/session/QuickBuildSessionState.kt | 3 ++ .../domain/session/SessionReducer.kt | 4 +- .../service/provision/ProxyAppBuildRunner.kt | 11 ++-- .../service/session/LiveReloadExecutorImpl.kt | 24 +++++++-- .../session/QuickBuildDaemonController.kt | 7 +++ .../session/QuickBuildSessionManager.kt | 29 ++++++++-- .../session/LiveReloadExecutorImplEdgeTest.kt | 6 ++- .../session/QuickBuildSessionManagerTest.kt | 53 +++++++++++++++++++ 10 files changed, 131 insertions(+), 17 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadExecutor.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadExecutor.kt index f8fc778cd0..fbbbd4c213 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadExecutor.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadExecutor.kt @@ -147,6 +147,15 @@ sealed interface BuildOutcome { val message: String, val daemonDied: Boolean = false, ) : BuildOutcome + + companion object { + /** + * Fallback copy for a messageless throwable escaping the pipeline. The class name is + * in the ERROR log line the catch already writes, where it helps; on the status + * surface it reads as gibberish. + */ + const val UNEXPECTED_FAILURE = "Quick Build stopped unexpectedly" + } } /** diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestrator.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestrator.kt index f532638a9d..9441897905 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestrator.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestrator.kt @@ -686,7 +686,7 @@ class LiveReloadOrchestrator( throw e } catch (e: Throwable) { log.error("Quick build #{} threw instead of reporting an outcome", buildId, e) - BuildOutcome.InfrastructureFailure(e.message ?: e.javaClass.name) + BuildOutcome.InfrastructureFailure(e.message ?: BuildOutcome.UNEXPECTED_FAILURE) } onBuildFinished(buildId, outcome) } 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 index 67cb573c06..e266bd3278 100644 --- 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 @@ -222,9 +222,12 @@ sealed interface SessionEvent { * * @property generation the generation the freshly installed proxy app starts at; every later * deploy must be strictly newer. + * @property askAlreadyAnswered the path that provisioned already brought the app forward for + * the outstanding tap, so landing must not switch to it a second time. */ data class ProvisioningSucceeded( val generation: Long, + val askAlreadyAnswered: Boolean = false, ) : SessionEvent /** 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 index 24f7527d1f..ea04db15fb 100644 --- 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 @@ -172,8 +172,8 @@ class SessionReducer { 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) { + // the editor, and one whose own relaunch already answered the tap says so. + if (state.userInitiated && !event.askAlreadyAnswered) { listOf(SessionEffect.StartWarmCompile, SessionEffect.SwitchToProxyApp) } else { listOf(SessionEffect.StartWarmCompile) 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 index ea80b0692b..d159bbb0c5 100644 --- 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 @@ -275,10 +275,11 @@ internal class ProxyAppBuildRunner( */ val baselineGeneration: Long, /** - * True when a user ask was outstanding and the runner relaunched the reinstalled - * app for it (best-effort, like every foreground switch: a refused start is - * logged, not retried). The manager drops its deferred ask on this, so the - * landing does not launch the app a second time for the same tap. + * True when a user ask was outstanding and the relaunch of the reinstalled app + * for it succeeded. A refused start leaves this false, so the ask stays + * outstanding and the landing answers it instead of dropping it on the floor. + * The manager clears its deferred ask on a true, and tells the reducer the ask + * is answered, so the app is not launched a second time for the same tap. */ val answeredUserAsk: Boolean, ) : ProxyAppRebuildResult @@ -403,7 +404,7 @@ internal class ProxyAppBuildRunner( outcome.proxyApp, outcome.layout, outcome.baselineGeneration, - answeredUserAsk = askOutstanding, + answeredUserAsk = askOutstanding && toRunningMillis != null, ) } 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 index 7fe0eff6f3..dd76605f0d 100644 --- 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 @@ -93,6 +93,15 @@ class LiveReloadExecutorImpl( */ @Volatile private var currentBuildUserInitiated = false + /** + * The newest generation a deploy confirmed live in the proxy app, or -1 before the first + * one. A build that deploys nothing must report this, not [GenerationTracker.current]: + * the tracker holds the newest generation ALLOCATED, which a failed deploy leaves ahead of + * the app, and reporting it advances the session's deploy tally to a generation the app + * never ran - which then forces a catch-up build on every reconnect. + */ + @Volatile private var lastConfirmedGeneration = -1L + private val payloadDeployer = PayloadDeployer( deploy = deploy, @@ -117,6 +126,7 @@ class LiveReloadExecutorImpl( try { currentBuildUserInitiated = request.userInitiated val outcome = executeInner(request) + if (outcome is BuildOutcome.Success) lastConfirmedGeneration = outcome.generation // 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 @@ -127,9 +137,17 @@ class LiveReloadExecutorImpl( throw e } catch (e: Throwable) { log.error("Quick build #{} pipeline failure", request.buildId, e) - BuildOutcome.InfrastructureFailure(e.message ?: e.javaClass.name) + BuildOutcome.InfrastructureFailure(e.message ?: BuildOutcome.UNEXPECTED_FAILURE) } + /** + * The generation the proxy app is running, for a build that deployed nothing. + * + * Falls back to the allocator before the session's first deploy, where the two agree by + * construction: provisioning adopts the installed baseline's stamp into the tracker. + */ + private fun liveGeneration(): Long = lastConfirmedGeneration.takeIf { it >= 0 } ?: generations.current + /** * Tells the proxy app about a build that shipped no payload, so it never runs old * code with nothing on screen to say why. @@ -228,7 +246,7 @@ class LiveReloadExecutorImpl( if (!request.forced) { // The orchestrator does not start empty unforced builds; answering // benignly keeps the executor total anyway. - BuildOutcome.Success(generations.current, 0) + BuildOutcome.Success(liveGeneration(), 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 @@ -309,7 +327,7 @@ class LiveReloadExecutorImpl( 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) + BuildOutcome.Success(liveGeneration(), clock() - loopStartedAt) } else { payloadDeployer.deploy(DeployDecision.Recreate, null, null, assets, loopStartedAt, timeline) } diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt index 5449a4bcca..5c0c91d08d 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildDaemonController.kt @@ -40,7 +40,11 @@ internal class QuickBuildDaemonController( * once for its start - while a lone shutdown bumps once. Nothing here enforces it: the * session manager's transition paths carry the obligation, and a flow that bumps a * different number of times silently breaks the zombie-versus-successor distinction. + * + * Volatile because the daemon's death listener reads it from the process reaper thread + * to tell a death this session caused from one it did not. */ + @Volatile private var daemonEpoch = 0L /** Set only on the session dispatcher; a build in flight defers the teardown here. */ @@ -69,6 +73,9 @@ internal class QuickBuildDaemonController( /** * The current epoch, captured at effect time and passed back into [respawn]. * + * Safe to call off the session dispatcher: the read is volatile, and the value is only + * ever compared with a later read. + * * @return an opaque counter, meaningful only when compared with a later read */ fun epochSnapshot(): Long = daemonEpoch 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 2fa77d4c8a..a5aedbd7c2 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 @@ -91,7 +91,7 @@ class QuickBuildSessionManager( 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. */ + /** Records this project's first Quick Build tap; the eager prebuild does not gate on it. */ private val historyStore: QuickBuildHistoryStore, /** * Confines everything stateful. Must be single-threaded: the orchestrator's @@ -363,7 +363,18 @@ class QuickBuildSessionManager( init { daemon.setDeathListener { exitCode -> log.warn("Quick-build daemon death observed (exit {})", exitCode) - scope.launch { dispatch(SessionEvent.DaemonDied) } + // The epoch is read here, on the reaper thread, not inside the coroutine: an + // intentional teardown and restart can both land while the dispatch is still + // queued, and comparing epochs then would compare the successor with itself + // and kill a healthy session over its predecessor's death. + val observedEpoch = daemonController.epochSnapshot() + scope.launch { + if (daemonController.epochSnapshot() != observedEpoch) { + log.info("Ignoring a daemon death this session's own transition caused") + return@launch + } + dispatch(SessionEvent.DaemonDied) + } } scope.launch { connections.reports.collect { report -> @@ -441,7 +452,12 @@ class QuickBuildSessionManager( scope.launch { dispatch(SessionEvent.QuickBuildTapped(wroteSomething)) try { - historyStore.setHasUsedQuickBuild(true) + // The write is a blocking commit on the single-threaded session dispatcher, + // and after the first tap it writes a value already there. The read is the + // cheap side, so let it carry every later tap. + if (!historyStore.hasUsedQuickBuild()) { + historyStore.setHasUsedQuickBuild(true) + } } catch (e: Throwable) { log.warn("Could not record Quick Build history for this project", e) } @@ -1248,7 +1264,12 @@ class QuickBuildSessionManager( } catch (e: Exception) { log.warn("Post-rebuild status clear failed", e) } - dispatch(SessionEvent.ProvisioningSucceeded(result.baselineGeneration)) + dispatch( + SessionEvent.ProvisioningSucceeded( + result.baselineGeneration, + askAlreadyAnswered = result.answeredUserAsk, + ), + ) } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Throwable) { 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 index 31538daf4f..372213cf1a 100644 --- 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 @@ -91,14 +91,16 @@ class LiveReloadExecutorImplEdgeTest { } @Test - fun `a message-less pipeline throw falls back to the exception class name`() = + fun `a message-less pipeline throw falls back to named copy, not the class name`() = runTest { val outcome = executor(clock = { throw IllegalStateException() }) .execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + // The message reaches the status surface verbatim, where a class name reads as + // gibberish; it stays in the ERROR log line the catch writes. assertThat(outcome) - .isEqualTo(BuildOutcome.InfrastructureFailure(IllegalStateException::class.java.name)) + .isEqualTo(BuildOutcome.InfrastructureFailure(BuildOutcome.UNEXPECTED_FAILURE)) } @Test 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 d865333274..345b37d8df 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 @@ -1533,6 +1533,41 @@ class QuickBuildSessionManagerTest { assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) } + @Test + fun `a tap landing mid-rebaseline is answered by the rebuild's own relaunch, not twice`() = + runTest { + // A tap that arrives while the rebaseline is already running records + // userInitiated on Provisioning, which makes the landing switch to the proxy + // app - on top of the relaunch the rebuild does itself for the same outstanding + // ask. Two presses, one relaunch: both want the same thing. + proxyAppRebuildOutcome = { + ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal("manifest does not build")) + } + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + manager.save(gradleFile) + advanceUntilIdle() + val launchesBefore = launches.size + + proxyAppRebuildOutcome = { defaultProxyAppRebuildSuccess() } + val rebGate = CompletableDeferred() + proxyAppRebuildGate = rebGate + manager.onQuickBuildTapped() + advanceUntilIdle() + // The rebaseline is in flight; the second press lands on Provisioning. + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(launches).hasSize(launchesBefore) + + rebGate.complete(Unit) + advanceUntilIdle() + + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + assertThat(launches).hasSize(launchesBefore + 1) + assertThat(rebuildRelaunches.last()).isTrue() + } + @Test fun `a failed proxy app rebuild surfaces the error and parks recoverable`() = runTest { @@ -2617,6 +2652,21 @@ class QuickBuildSessionManagerTest { assertThat(historyStore.hasUsedQuickBuild()).isTrue() } + @Test + fun `a tap on a project that already used Quick Build skips the history write`() = + runTest { + // The write is a blocking preference commit on the single-threaded session + // dispatcher; after the first tap it writes a value already there. + val manager = createManager() + var writes = 0 + historyStore.onWrite = { writes++ } + + manager.onQuickBuildTapped() + advanceUntilIdle() + + assertThat(writes).isEqualTo(0) + } + @Test fun `the tap reaches the reducer before the history write, not after it`() = runTest { @@ -2627,6 +2677,8 @@ class QuickBuildSessionManagerTest { // primary control looks like. var stateAtWrite: QuickBuildSessionState? = null val manager = createManager() + // Only the first tap on a project writes, so the store must not already say used. + historyStore.setHasUsedQuickBuild(false) historyStore.onWrite = { stateAtWrite = manager.state.value } manager.onQuickBuildTapped() @@ -2642,6 +2694,7 @@ class QuickBuildSessionManagerTest { // 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.setHasUsedQuickBuild(false) historyStore.writeError = IllegalStateException("no project open") val manager = createManager() From 2adbbdff2adca419fc1f1622ecdb7bd244930720 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 13:12:33 -0700 Subject: [PATCH 6/9] ADFA-4128: five session-lifecycle fixes from the 0903 review round Answers review threads 3926554702, 3926554709, 3926554719, 3926555512 and the duplicate daemon-death thread on PR #1720. - teardown cancels the orchestrator's in-flight build before shutting the daemon down, so no compile is left running against a daemon that is going away and writing into a scratch tree the teardown is about to remove - the session scope carries a CoroutineExceptionHandler; five effect launches call straight into the orchestrator or the daemon with no boundary of their own - a build variant selected during the provisioning window is re-checked once the session goes live, instead of being dropped with nothing to correct it later - the warm-compile early return reports the generation the app is running, not the allocator's, which could be ahead of it after a build that never deployed - one physical daemon death has two reporters that cannot see each other; a second report from the OTHER reporter is now recognised as the same death, which stopped a successful respawn from being refused Each is pinned by a test that fails without it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../service/provision/ProxyAppBuildRunner.kt | 4 +- .../service/session/LiveReloadExecutorImpl.kt | 2 +- .../session/QuickBuildSessionManager.kt | 124 +++++++++++++++- .../session/LiveReloadExecutorImplTest.kt | 17 +++ .../session/QuickBuildSessionManagerTest.kt | 137 +++++++++++++++++- 5 files changed, 273 insertions(+), 11 deletions(-) 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 index d159bbb0c5..ad0f15dd9b 100644 --- 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 @@ -163,8 +163,8 @@ internal class ProxyAppBuildRunner( } // 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. + // would reach the session scope's handler, which only logs - leaving CoGo + // running with a uid session already registered and nothing to undo it. var sessionBegun = false var daemonStarted = false try { 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 index dd76605f0d..8f3644d2c8 100644 --- 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 @@ -227,7 +227,7 @@ class LiveReloadExecutorImpl( // timeline to report. val dex = compileAndDex(ChangedFiles.Unknown, timeline) if (dex is Step.Fail) return dex.outcome - return BuildOutcome.Success(generations.current, clock() - loopStartedAt) + return BuildOutcome.Success(liveGeneration(), clock() - loopStartedAt) } val known = request.changes as? ChangedFiles.Known 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 a5aedbd7c2..c94b24466b 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 @@ -2,6 +2,7 @@ package org.appdevforall.cotg.quickbuild.service.session import android.content.ComponentCallbacks2 import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -182,9 +183,50 @@ class QuickBuildSessionManager( ): LiveReloadExecutor } - private val scope = CoroutineScope(SupervisorJob() + dispatcher) + /** + * Last line for an effect launch whose callee broke a no-throw contract. Five of them - + * the reload trigger, the user-initiated mark, the cancel, the baseline refresh and the + * daemon respawn - call straight into the orchestrator or the daemon with no boundary of + * their own, and without this the throw reaches the global handler and takes CoGo down + * instead of leaving a session the user can restart. + */ + private val effectExceptionHandler = + CoroutineExceptionHandler { _, e -> + log.error("Quick Build session work failed unexpectedly", e) + } + + private val scope = CoroutineScope(SupervisorJob() + dispatcher + effectExceptionHandler) private val reducer = SessionReducer() + /** Who told the session a daemon had died. See [reportDaemonDeath]. */ + private enum class DeathReporter { + /** The daemon's own process-exit watcher; fires exactly once per physical death. */ + WATCHER, + + /** The build in flight, which fails with `daemonDied`; at most one build runs at a time. */ + BUILD, + } + + /** + * Who reported this daemon's death, or null once a daemon is back up. + * + * One physical death has two independent reporters that cannot see each other, and each + * reports any given death at most once - so a second report from the OTHER reporter is + * that same death, while a second report from the SAME one is a new death. Only touched on + * the session dispatcher. + */ + private var lastDeathReporter: DeathReporter? = null + + /** + * The most recent Build Variants selection a sync reported, or null when none has. + * + * Kept because a selection applied during provisioning has no live session to compare + * against, and the sync that applied it is the one that just ran - so without re-checking + * when the session goes live, nothing would ever notice. Only touched on the session + * dispatcher. + */ + private var lastSyncedVariant: String? = null + private val _state = MutableStateFlow(QuickBuildSessionState.Idle()) @@ -373,7 +415,7 @@ class QuickBuildSessionManager( log.info("Ignoring a daemon death this session's own transition caused") return@launch } - dispatch(SessionEvent.DaemonDied) + reportDaemonDeath(DeathReporter.WATCHER) } } scope.launch { @@ -524,6 +566,10 @@ class QuickBuildSessionManager( */ fun onProjectSynced(selectedVariant: String? = null) { scope.launch { + // Kept whether or not there is a session to compare against: with none there is + // no provisioned variant yet, and [checkProvisionedVariant] does the comparison + // when the session goes live. + if (selectedVariant != null) lastSyncedVariant = selectedVariant val provisioned = live?.provisionedVariant if (provisioned != null && selectedVariant != null && provisioned != selectedVariant) { log.info( @@ -1029,16 +1075,19 @@ class QuickBuildSessionManager( // 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) + // A daemon is up for this session, so the next death is a new one. + lastDeathReporter = null dispatch(SessionEvent.ProvisioningSucceeded(result.baselineGeneration)) + checkProvisionedVariant() } 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. + // the manager's half of the same assembly. The scope's handler would only + // log it, leaving the daemon up and the uid session registered; caught here + // instead, and since [live] is already set the failure effect's teardown + // unwinds both. log.error("Installing the provisioned quick-build session threw", e) // Messageless throw: the class name lives in the log line above, not on // the banner. @@ -1074,7 +1123,11 @@ class QuickBuildSessionManager( routing.newLastDeployedGeneration?.let { generation -> session?.lastDeployedGeneration = generation } - routing.sessionEvents.forEach { dispatch(it) } + routing.sessionEvents.forEach { + // The in-flight build is the second reporter of one physical death, so its + // DaemonDied goes through the same de-duplication as the death watcher's. + if (it is SessionEvent.DaemonDied) reportDaemonDeath(DeathReporter.BUILD) else 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. @@ -1415,6 +1468,8 @@ class QuickBuildSessionManager( val session = live ?: return when (val outcome = daemonController.respawn(session.layout, session.proxyApp, startEpoch)) { is QuickBuildDaemonController.RespawnOutcome.Respawned -> { + // A daemon is back, so the next death is a new one to report. + lastDeathReporter = null 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 @@ -1466,10 +1521,18 @@ class QuickBuildSessionManager( sessionWork = null live?.watcher?.stop() val scratchOwner = live?.layout?.projectRoot + // The orchestrator's build runs on this manager's process-lifetime scope, which + // [sessionWork] does not cover and nothing else cancels. Captured before [live] is + // cleared, so teardownWork can stop it. + val abandonedOrchestrator = live?.orchestrator live = null connections.endSession() + lastDeathReporter = null teardownWork = scope.launch { + // Before the shutdown, so no compile is left running against a daemon this + // teardown is stopping, writing into the scratch tree it is about to remove. + abandonedOrchestrator?.onCancelRequested() 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 @@ -1482,6 +1545,53 @@ class QuickBuildSessionManager( } } + /** + * Reprovisions when the session that just went live is for the wrong build variant. + * + * [onProjectSynced] can only compare against a live session, so a Build Variants selection + * applied during the provisioning window - the Gradle build, the install prompt and the + * daemon spawn - is dropped, and the sync that applied it is the one that just ran, so + * nothing corrects it later. The session then hot-reloads into the old variant, which is + * the "user edits one app and watches another" [onProjectSynced] exists to prevent. + * + * The selection is consumed here, so a provisioner that keeps producing the old variant + * costs one extra reprovision per sync rather than looping. + */ + private suspend fun checkProvisionedVariant() { + val selected = lastSyncedVariant ?: return + val provisioned = live?.provisionedVariant ?: return + if (provisioned == selected) return + lastSyncedVariant = null + log.info( + "Session went live on variant {} but {} is selected; reprovisioning", + provisioned, + selected, + ) + dispatch(SessionEvent.SessionRestartAndReprovisionRequested(userInitiated = false)) + } + + /** + * Dispatches [SessionEvent.DaemonDied], unless the other reporter already reported it. + * + * A second report from the OTHER reporter is the same death seen twice, and dispatching it + * lands a DaemonDied in Degraded, which sets `restartFailed` - so the respawn that then + * succeeds is refused and the session sits behind "restart failed" with a live compiler + * until some later save recovers it. A second report from the SAME reporter is a new death + * (each reports a given death once), which is the respawned child dying during its own + * start, and that one must go through. + * + * @param reporter which of the two saw it + */ + private suspend fun reportDaemonDeath(reporter: DeathReporter) { + val previous = lastDeathReporter + if (previous != null && previous != reporter) { + log.debug("Quick Build: {} re-reported a death {} already reported; ignored", reporter, previous) + return + } + lastDeathReporter = reporter + dispatch(SessionEvent.DaemonDied) + } + /** * Queues failure text for [userMessages], for whenever the editor is next on screen. * 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 index 9c0c09ca9e..63aff64d2f 100644 --- 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 @@ -170,6 +170,23 @@ class LiveReloadExecutorImplTest { assertThat(tracker.current).isEqualTo(0) } + @Test + fun `a warm compile after a failed deploy reports the generation the app still runs`() = + runTest { + // The warm compile deploys nothing, so its Success must name the generation the app + // is on. The allocator has already moved past it whenever a build allocated a + // generation the app never loaded, and reporting that one tells the session a payload + // the proxy app never received is live. + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + deploy.result = DeployResult.Crashed("NPE in onCreate") + executor.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + assertThat(tracker.current).isEqualTo(2) + + val outcome = executor.execute(request(BuildRoute.WarmCompile, ChangedFiles.Unknown)) + + assertThat(outcome).isEqualTo(BuildOutcome.Success(1, 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. 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 345b37d8df..251fb5f937 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 @@ -165,6 +165,9 @@ class QuickBuildSessionManagerTest { */ private var executorFactoryError: (() -> Throwable)? = null + /** Set by the scripted executor when its mid-build wait was cancelled. */ + private var executionCancelled = false + /** 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 @@ -356,7 +359,14 @@ class QuickBuildSessionManagerTest { ?: BuildOutcome.Success(tracker.current, 5) } executed += request - executionGate?.await() + executionGate?.let { gate -> + try { + gate.await() + } catch (e: kotlinx.coroutines.CancellationException) { + executionCancelled = true + throw e + } + } return scriptedOutcomes.removeFirstOrNull() ?: BuildOutcome.Success(tracker.next(), 5) } @@ -4971,4 +4981,129 @@ class QuickBuildSessionManagerTest { advanceUntilIdle() assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Deployed(1, 5)) } + + @Test + fun `tearing the session down cancels the build it abandons`() = + runTest { + // The orchestrator's build runs on the manager's process-lifetime scope, which the + // session job does not cover. Left running, it compiles against a daemon the teardown + // is stopping and writes into the scratch tree the teardown is about to remove. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + val gate = CompletableDeferred() + executionGate = gate + + manager.save(sourceFile) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Building(0)) + + manager.restartSession() + advanceUntilIdle() + + assertThat(executionCancelled).isTrue() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle()) + } + + @Test + fun `an effect launch whose callee throws leaves a session the user can recover`() = + runTest { + // The respawn effect calls straight into the daemon with no boundary of its own, and + // a spawn that throws rather than replying Failed breaks that no-throw contract. With + // nothing on the scope to catch it the throw leaves the session's own scope and takes + // CoGo down instead of leaving a compiler the user can restart. + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + daemon.onStart = { + daemon.onStart = {} + throw IllegalStateException("spawn boom") + } + daemon.die(exitCode = 137) + advanceUntilIdle() + + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Degraded(0)) + + // The scope survived the throw: a fresh session still comes up on it. + manager.restartSession() + advanceUntilIdle() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `a build variant selected during provisioning reprovisions once the session goes live`() = + runTest { + // The selection lands in the provisioning window - the Gradle build, the install + // prompt and the daemon spawn - where there is no live session to compare it against. + // The sync that applied it is the one that just ran, so nothing corrects it later and + // the session hot-reloads into the variant the user navigated away from. + val gate = CompletableDeferred() + provisionGate = gate + var call = 0 + provisionOutcome = { + call++ + defaultProvisionOutcome(if (call == 1) "demoDebug" else "fullDebug") + } + + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + assertThat(provisionCount).isEqualTo(1) + + manager.onProjectSynced("fullDebug") + advanceUntilIdle() + + provisionGate = null + gate.complete(Unit) + advanceUntilIdle() + + assertThat(provisionCount).isEqualTo(2) + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0)) + } + + @Test + fun `one daemon death reported by both its reporters still recovers to Ready`() = + runTest { + // A death has two reporters that cannot see each other: the death watcher, and the + // build in flight, which fails with daemonDied. The second lands from Degraded, where + // DaemonDied sets restartFailed, and the respawn that then succeeds is refused - so a + // live compiler stays hidden behind "restart failed" until some later save recovers it. + scriptedOutcomes += BuildOutcome.InfrastructureFailure("pipe broke", daemonDied = true) + val manager = createManager() + manager.onQuickBuildTapped() + advanceUntilIdle() + + val buildGate = CompletableDeferred() + executionGate = buildGate + manager.save(sourceFile) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Building(0)) + + // The watcher reports first, and its respawn is held open so the second report lands + // from Degraded - the ordering the bug needs. + val respawnGate = CompletableDeferred() + daemon.startGate = respawnGate + daemon.die(exitCode = 137) + advanceUntilIdle() + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Degraded(0)) + + // Now the same death arrives a second time, as the in-flight build's own failure. + executionGate = null + buildGate.complete(Unit) + advanceUntilIdle() + // Still the honest "restarting" window, not "restart failed": nothing new died. + assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Degraded(0)) + + respawnGate.complete(Unit) + advanceUntilIdle() + + // The stale report did not set restartFailed, so the respawn was accepted: the + // daemon is back and the save that was in flight when it died has been re-seeded. + assertThat(daemon.isRunning).isTrue() + assertThat(manager.state.value) + .isEqualTo(QuickBuildSessionState.Deployed(1, buildDurationMillis = 5)) + } } From dac5733362d19e8fe6bc8cd41a8893508c21fb14 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 13:12:41 -0700 Subject: [PATCH 7/9] ADFA-4128: name the guard on the Provisioning to Ready edge in the state diagram Answers review thread 3926554735 on PR #1720. The diagram showed the edge as unconditional; the reducer only emits SwitchToProxyApp when the provision was user-initiated and the ask has not already been answered. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../org/appdevforall/cotg/quickbuild/domain/session/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 5b115cb4bc..aee9e456bb 100644 --- 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 @@ -31,7 +31,7 @@ stateDiagram-v2 Prebuilding --> Idle: PrebuildFinished (no tap) Prebuilding --> Idle: CancelRequested (tap queued) - Provisioning --> Ready: ProvisioningSucceeded (SwitchToProxyApp if userInitiated) + Provisioning --> Ready: ProvisioningSucceeded (SwitchToProxyApp if userInitiated and not askAlreadyAnswered) Provisioning --> Provisioning: QuickBuildTapped (records the ask; userInitiated = true) Provisioning --> Idle: ProvisioningFailed Provisioning --> Idle: CancelRequested From d4c5c9ab1d18737ac1fefc5e81142eab144796c3 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 16:43:50 -0700 Subject: [PATCH 8/9] ADFA-4128: a daemon that will not start reports a named message, not its English sentence DaemonProcessClient writes English into DaemonReply.Failed.message and the provisioner passed it to the user verbatim as a Literal, which the message type documents as never a sentence written in this module. A new DaemonStartFailed case carries the reason as detail, the way DaemonRestartFailed does; the host renders it inside localized copy. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../quickbuild/domain/session/QuickBuildMessage.kt | 10 ++++++++++ .../service/provision/ProxyAppBuildRunner.kt | 2 +- .../service/provision/ProxyAppBuildRunnerEdgeTest.kt | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildMessage.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildMessage.kt index 349d108d65..b6f9f2774e 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildMessage.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildMessage.kt @@ -108,6 +108,16 @@ sealed interface QuickBuildMessage { /** The compile daemon refused the configuration it was started with. */ data object DaemonRejectedConfiguration : QuickBuildMessage + /** + * The compile daemon could not be started, so provisioning failed before any build ran. + * + * @property detail the daemon client's own reason (a spawn error, a protocol version + * mismatch, a missing reply), diagnostic rather than translatable + */ + data class DaemonStartFailed( + val detail: String, + ) : QuickBuildMessage + /** * The compile daemon died and could not be restarted, so the session stays degraded until * the next tap or a session restart retries. 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 index ad0f15dd9b..8b69972b63 100644 --- 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 @@ -195,7 +195,7 @@ internal class ProxyAppBuildRunner( } is DaemonReply.Failed -> { - ProvisionResult.Failed(QuickBuildMessage.Literal(started.message)) + ProvisionResult.Failed(QuickBuildMessage.DaemonStartFailed(started.message)) } } } catch (e: kotlinx.coroutines.CancellationException) { 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 index 6defd91d66..edd6d547b0 100644 --- 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 @@ -157,7 +157,7 @@ class ProxyAppBuildRunnerEdgeTest { val result = runner().provision(superseded = { false }) - assertThat(result).isEqualTo(ProxyAppBuildRunner.ProvisionResult.Failed(QuickBuildMessage.Literal("jdk missing"))) + assertThat(result).isEqualTo(ProxyAppBuildRunner.ProvisionResult.Failed(QuickBuildMessage.DaemonStartFailed("jdk missing"))) } @Test From 3e7dd83e90a69a2eea629bce7f01762555051b23 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 17:30:35 -0700 Subject: [PATCH 9/9] ADFA-4128: take the layout's tree walks off the session dispatcher The session dispatcher is one thread and concurrency.md's rule for it is that nothing on it may block. Four call sites broke that rule: session start read watchedRoots() and watchedFiles() for the filter and again for the watcher, and each of those re-walks the project root; the annotation baseline walks and reads every source file; and the executor scans all sources on every build. Each of the four now hops with withContext to an injected IO dispatcher. Session start also reads the two watch accessors inside ONE hop, so it does two walks off-thread where it used to do four on it. The dispatcher is injected rather than hard-coded because a real Dispatchers.IO escapes runTest's virtual time - with the hop hard-coded, 142 of the session manager's 182 tests went red. It threads manager -> factory -> executor, and the manager's tests put it on their own scheduler. Tests record which thread did the work. The session-start one ties the assertion to the walk itself, through a project root that reports the thread that listed it; the other two count hops on a recording dispatcher, which is zero without the fix. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../service/session/LiveReloadExecutorImpl.kt | 13 ++- .../service/session/LiveSessionFactory.kt | 31 +++++- .../session/QuickBuildSessionManager.kt | 8 ++ .../cotg/quickbuild/service/Fakes.kt | 38 ++++++++ .../session/LiveReloadExecutorImplTest.kt | 38 ++++++++ .../service/session/LiveSessionFactoryTest.kt | 94 +++++++++++++++++-- .../session/QuickBuildSessionManagerTest.kt | 3 + 7 files changed, 209 insertions(+), 16 deletions(-) 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 index 8f3644d2c8..a522db41d7 100644 --- 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 @@ -1,6 +1,9 @@ package org.appdevforall.cotg.quickbuild.service.session import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.appdevforall.cotg.quickbuild.data.AssetPackager import org.appdevforall.cotg.quickbuild.data.DaemonReply import org.appdevforall.cotg.quickbuild.data.QuickBuildDaemon @@ -75,6 +78,12 @@ class LiveReloadExecutorImpl( * sink; the default no-op keeps existing callers and tests unchanged. */ private val metrics: QuickBuildMetricsSink = QuickBuildMetricsSink.Noop, + /** + * Where the source scan runs. [QuickBuildProjectLayout.allSources] walks the source + * roots on every build, and this executor runs on the single session dispatcher, whose + * rule is that nothing on it may block. Injected so a test can record the thread. + */ + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : LiveReloadExecutor { /** Builds the changed-assets zip. */ private val assetPackager = AssetPackager() @@ -365,7 +374,9 @@ class LiveReloadExecutorImpl( // 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() + // Walks the source roots, so it is hopped off the session dispatcher; the span + // still measures the whole scan, since withContext suspends until it returns. + val allSources = withContext(ioDispatcher) { layout.allSources() } val scanDoneAt = clock() timeline.recordScan(scanDoneAt - scanStartedAt) val changedSources = 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 index 235c390a69..9616e5461a 100644 --- 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 @@ -1,6 +1,9 @@ package org.appdevforall.cotg.quickbuild.service.session +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo import org.appdevforall.cotg.quickbuild.data.QuickBuildDaemon import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout @@ -60,6 +63,14 @@ internal class LiveSessionFactory( private val onOrchestratorEvent: (OrchestratorEvent) -> Unit, /** This device's asset-serving capability; see [ChangeClassifier]'s parameter of the same name. */ private val assetsLiveReloadable: Boolean, + /** + * Where the layout's tree walks run. [QuickBuildProjectLayout.watchedRoots], + * [QuickBuildProjectLayout.watchedFiles] and [QuickBuildProjectLayout.allSources] each + * walk the project root, and this factory is called on the single session dispatcher, + * whose rule is that nothing on it may block. Injected so a test can record which + * thread the walk ran on. + */ + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { /** * Wires a session around the provisioned proxy app, ready to accept edits. @@ -69,11 +80,16 @@ internal class LiveSessionFactory( * outlives a baseline swap * @return the assembled session; its watcher is created but not yet started */ - fun create( + suspend fun create( outcome: ProvisionOutcome.Success, tracker: GenerationTracker, ): LiveSession { val layout = outcome.layout + // Both accessors walk the project root, and both used to walk it twice over - once + // for the filter and once for the watcher. Hopped off the session dispatcher and + // read once, so session start does two walks off-thread instead of four on it. + val (watchedRoots, watchedFiles) = + withContext(ioDispatcher) { layout.watchedRoots() to layout.watchedFiles() } val proxyApp = outcome.proxyApp val executor = SwitchableExecutor(executorFor(proxyApp, layout, tracker)) val annotationImpact = SwitchableAnnotationImpact(annotationImpactFor(proxyApp, layout)) @@ -90,14 +106,14 @@ internal class LiveSessionFactory( now = nowMillis, onEvent = onOrchestratorEvent, ) - val filter = WatchFilter(layout.watchedRoots(), layout.watchedFiles()) + val filter = WatchFilter(watchedRoots, watchedFiles) return LiveSession( proxyApp = outcome.proxyApp, layout = layout, tracker = tracker, filter = filter, orchestrator = orchestrator, - watcher = watcherFactory.create(layout.watchedRoots(), layout.watchedFiles(), filter, scope), + watcher = watcherFactory.create(watchedRoots, watchedFiles, filter, scope), executor = executor, annotationImpact = annotationImpact, // The same location executorFor's executor writes into, so the manager's @@ -154,6 +170,7 @@ internal class LiveSessionFactory( launcher = launcher, clock = nowMillis, metrics = metrics, + ioDispatcher = ioDispatcher, ) /** @@ -169,7 +186,7 @@ internal class LiveSessionFactory( * @return an analyzer over the captured baseline, or [AnnotationImpact.Inactive] when * the project runs no processors */ - fun annotationImpactFor( + suspend fun annotationImpactFor( proxyApp: ProxyAppInfo, layout: QuickBuildProjectLayout, ): AnnotationImpact { @@ -179,7 +196,11 @@ internal class LiveSessionFactory( "Quick build: annotation-aware classification on for processors {}", profile.processorCoordinates, ) - return AnnotationImpactAnalyzer(profile, AnnotationBaseline.capture(layout.allSources(), profile)) + // allSources walks the source roots; the capture that reads each file is disk work + // too, so the whole baseline is taken off the session dispatcher rather than just + // the listing. + val baseline = withContext(ioDispatcher) { AnnotationBaseline.capture(layout.allSources(), profile) } + return AnnotationImpactAnalyzer(profile, baseline) } private companion object { 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 c94b24466b..5164b4bc48 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 @@ -4,6 +4,7 @@ import android.content.ComponentCallbacks2 import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.BufferOverflow @@ -99,6 +100,12 @@ class QuickBuildSessionManager( * event-ordering guarantee depends on it. */ dispatcher: CoroutineDispatcher, + /** + * Where the layout's tree walks run, off [dispatcher]. Injected rather than hard-coded + * so a test can put them on its own scheduler instead of a real thread pool, which + * would otherwise escape virtual time. + */ + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, /** Opens the project's persisted generation counter, keyed by its root directory. */ private val generationStoreFactory: (File) -> GenerationStore = { FileGenerationStore.forProject(it) @@ -380,6 +387,7 @@ class QuickBuildSessionManager( scope = scope, onOrchestratorEvent = ::onOrchestratorEvent, assetsLiveReloadable = assetsLiveReloadable, + ioDispatcher = ioDispatcher, ) /** 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 27ddb579ab..b4222bbde6 100644 --- a/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt +++ b/quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt @@ -1,6 +1,7 @@ package org.appdevforall.cotg.quickbuild.service import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import org.appdevforall.cotg.quickbuild.data.CompileOutput @@ -16,6 +17,9 @@ 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 +import java.util.Collections +import java.util.concurrent.Executors +import kotlin.coroutines.CoroutineContext /** Scripted [QuickBuildDaemon]: every op records its arguments and replies per script. */ class FakeDaemon : QuickBuildDaemon { @@ -247,3 +251,37 @@ class FakeQuickBuildHistoryStore : QuickBuildHistoryStore { this.used = used } } + +/** + * Dispatcher that runs every block on one named thread and counts the hops. + * + * Lets a test assert that work was moved off the caller's thread, which is the rule the + * single session dispatcher lives by (concurrency.md: nothing on that thread may block). + */ +class RecordingIoDispatcher : CoroutineDispatcher() { + /** Names of the threads blocks actually ran on; one entry per distinct thread. */ + val threads: MutableSet = Collections.synchronizedSet(mutableSetOf()) + + /** How many blocks were handed to this dispatcher; zero means nothing hopped. */ + @Volatile + var dispatches: Int = 0 + private set + + private val executor = Executors.newSingleThreadExecutor { Thread(it, THREAD_NAME) } + + override fun dispatch( + context: CoroutineContext, + block: Runnable, + ) { + dispatches++ + executor.execute { + threads += Thread.currentThread().name + block.run() + } + } + + companion object { + /** The one thread this dispatcher runs on, so a test can name it in an assertion. */ + const val THREAD_NAME = "qb-test-io" + } +} 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 index 63aff64d2f..9af7ee384c 100644 --- 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 @@ -25,6 +25,7 @@ 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.RecordingIoDispatcher import org.appdevforall.cotg.quickbuild.service.deploy.DeployResult import org.appdevforall.cotg.quickbuild.service.deploy.RetainedPayloadStore import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppLauncher @@ -1684,4 +1685,41 @@ class LiveReloadExecutorImplTest { assertThat(launchCalls).hasSize(1) assertThat((outcome as BuildOutcome.DeployFailure).proxyAppNotConnected).isFalse() } + + /** + * The source scan walks the module's source roots on every build, and the executor runs + * on the single session dispatcher, whose rule is that nothing on it may block + * (concurrency.md). Goes red if the withContext around allSources is removed: the scan + * then runs inline and nothing is ever handed to the dispatcher. + */ + @Test + fun `the source scan runs off the caller's thread`() = + runTest { + val io = RecordingIoDispatcher() + daemon.dexReply = + DaemonReply.Ok( + DexOutput( + File(projectRoot, "built/classes.dex").apply { + parentFile!!.mkdirs() + writeText("dex-bytes") + }, + ), + ) + val scanning = + LiveReloadExecutorImpl( + daemon = daemon, + deploy = deploy, + layout = QuickBuildProjectLayout(projectRoot), + entryActivity = "com.example.MainActivity", + generations = tracker, + workDir = File(projectRoot, ".androidide/quickbuild"), + clock = { 1000L }, + ioDispatcher = io, + ) + + scanning.execute(request(BuildRoute.CodeOnly, ChangedFiles.Known(setOf(sourceFile)))) + + assertThat(io.dispatches).isEqualTo(1) + assertThat(io.threads).containsExactly(RecordingIoDispatcher.THREAD_NAME) + } } 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 index 64bb8483ac..838ca29ada 100644 --- 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 @@ -1,6 +1,7 @@ package org.appdevforall.cotg.quickbuild.service.session import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest @@ -24,6 +25,7 @@ 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.RecordingIoDispatcher import org.appdevforall.cotg.quickbuild.service.deploy.DeployResult import org.appdevforall.cotg.quickbuild.service.provision.ProvisionOutcome import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppLauncher @@ -31,6 +33,7 @@ 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.Collections class LiveSessionFactoryTest { @TempDir lateinit var projectRoot: File @@ -59,6 +62,7 @@ class LiveSessionFactoryTest { "not used by these seams", ) }, + ioDispatcher: CoroutineDispatcher = RecordingIoDispatcher(), ) = LiveSessionFactory( daemon = daemon, deploy = deploy, @@ -75,6 +79,7 @@ class LiveSessionFactoryTest { scope = CoroutineScope(StandardTestDispatcher()), onOrchestratorEvent = {}, assetsLiveReloadable = true, + ioDispatcher = ioDispatcher, ) /** A watcher that observes nothing; [create]'s retention seam never starts it. */ @@ -234,18 +239,87 @@ class LiveSessionFactoryTest { } @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) - } + fun `a project with no annotation processors gets Inactive annotation impact`() = + runTest { + 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(), + fun `a project with annotation processors gets an active analyzer`() = + runTest { + val impact = + factory().annotationImpactFor( + proxyApp(schema = 2, annotationProcessors = listOf("androidx.room:room-compiler:2.6.1")), + layout(), + ) + assertThat(impact.active).isTrue() + } + + /** + * The session dispatcher is one thread and concurrency.md's rule for it is that nothing + * on it may block, so the layout's tree walks must not run there. This pins the hop by + * recording, from inside the walk itself, which thread listed the project root. + * + * Goes red if the withContext around the two accessors is removed: the walk then runs on + * the caller's thread and the recorded names stop being the IO dispatcher's. + */ + @Test + fun `session start walks the project tree off the caller's thread`() = + runTest { + val walkThreads = Collections.synchronizedList(mutableListOf()) + val io = RecordingIoDispatcher() + val callerThread = Thread.currentThread().name + + factory(watcherFactory = { _, _, _, _ -> NoopWatcher }, ioDispatcher = io).create( + ProvisionOutcome.Success( + proxyApp = proxyApp(schema = 2), + proxyAppUid = 10123, + layout = QuickBuildProjectLayout(RecordingRoot(projectRoot.path, walkThreads)), + ), + GenerationTracker(MemoryGenerationStore()), ) - assertThat(impact.active).isTrue() + + assertThat(walkThreads).isNotEmpty() + assertThat(walkThreads.toSet()).containsExactly(RecordingIoDispatcher.THREAD_NAME) + assertThat(walkThreads).doesNotContain(callerThread) + // One hop, not two: both accessors are read inside the same withContext, which + // also halves the walks session start used to do. + assertThat(io.dispatches).isEqualTo(1) + } + + /** + * The same rule for the annotation baseline, whose capture both lists and reads every + * source file. Goes red if its withContext is removed. + */ + @Test + fun `the annotation baseline is captured off the caller's thread`() = + runTest { + val io = RecordingIoDispatcher() + + val impact = + factory(ioDispatcher = io).annotationImpactFor( + proxyApp(schema = 2, annotationProcessors = listOf("androidx.room:room-compiler:2.6.1")), + layout(), + ) + + assertThat(impact.active).isTrue() + assertThat(io.dispatches).isEqualTo(1) + assertThat(io.threads).containsExactly(RecordingIoDispatcher.THREAD_NAME) + } + + /** + * A project root that records which thread listed it, so a test can tie a tree walk to a + * dispatcher rather than only counting hops. [java.io.File.listFiles] is what + * `walkTopDown` calls on each directory it enters. + */ + private class RecordingRoot( + path: String, + private val threads: MutableList, + ) : File(path) { + override fun listFiles(): Array? { + threads += Thread.currentThread().name + return super.listFiles() + } } } 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 251fb5f937..24e0df0b8c 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 @@ -343,6 +343,9 @@ class QuickBuildSessionManagerTest { paths = FakePaths(projectRoot), historyStore = historyStore, dispatcher = StandardTestDispatcher(testScheduler), + // The layout's tree walks hop off the session dispatcher; keep them on the test + // scheduler so they stay inside virtual time rather than on a real IO thread. + ioDispatcher = StandardTestDispatcher(testScheduler), generationStoreFactory = { store }, executorFactory = { proxyApp, _, tracker -> executorFactoryError?.let { throw it() }