Skip to content

Commit c502024

Browse files
fryanpanclaude
andcommitted
ADFA-4128: qb 08 review fixes — cancel sequencing, provision tail, deploy-throw containment
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W
1 parent e3a72ab commit c502024

3 files changed

Lines changed: 179 additions & 26 deletions

File tree

quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManager.kt

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,10 @@ class QuickBuildSessionManager(
259259
* Whether [SessionEffect.CancelProxyAppBuild] already cancelled this session's Gradle build,
260260
* so [teardown] does not ask a second time. The stop-tap path emits that effect and a
261261
* teardown; every OTHER teardown (a restart, an invalidation, a project close) emits only the
262-
* teardown, which is the case teardown's own cancel exists for. Cleared in [teardown], which
263-
* always follows the effect.
262+
* teardown, which is the case teardown's own cancel exists for. Cleared in [teardown], and
263+
* again whenever an effect launches new session work: the Prebuilding stop drops a queued
264+
* tap with the cancel effect but NO teardown, and left latched the flag would make the next
265+
* session's teardown skip the cancel of a Gradle build it never covered.
264266
*/
265267
private var proxyAppBuildCancelIssued = false
266268

@@ -654,11 +656,16 @@ class QuickBuildSessionManager(
654656
private fun runEffect(effect: SessionEffect) {
655657
when (effect) {
656658
SessionEffect.StartProvisioning -> {
659+
// A cancel issued against a previous build does not cover the one starting
660+
// here; see [proxyAppBuildCancelIssued] for the Prebuilding stop that
661+
// latches it with no teardown to clear it.
662+
proxyAppBuildCancelIssued = false
657663
val epoch = sessionEpoch
658664
sessionWork = scope.launch { provision(epoch) }
659665
}
660666

661667
SessionEffect.StartProxyAppPrebuild -> {
668+
proxyAppBuildCancelIssued = false
662669
sessionWork = scope.launch { runPrebuild() }
663670
}
664671

@@ -718,6 +725,7 @@ class QuickBuildSessionManager(
718725
}
719726

720727
SessionEffect.RunProxyAppRebuild -> {
728+
proxyAppBuildCancelIssued = false
721729
val epoch = sessionEpoch
722730
sessionWork = scope.launch { rebuildProxyApp(epoch) }
723731
}
@@ -964,22 +972,35 @@ class QuickBuildSessionManager(
964972
live = result.session
965973
staleComponentHelpersNoticed = false
966974
testSourceIgnoredNoticed = false
967-
// A same-project predecessor's scratch tree can survive its teardown (see
968-
// [teardown]'s skip when a new session went live mid-shutdown); whatever it
969-
// retained belongs to another baseline and must not answer this session's
970-
// reconnects.
971-
result.session.retainedPayloads.clear()
972-
// The installed APK boots at the stamped baseline generation (concurrency.md
973-
// rule 2): the allocator must stay strictly above it, and adopting it as the
974-
// deploy tally makes a reconnect at the stamp read in-sync by construction.
975-
result.tracker.adoptAtLeast(result.baselineGeneration)
976-
result.session.lastDeployedGeneration = result.baselineGeneration
977-
// Build ids restart per session; give the sink its session boundary.
978-
report { metrics.onSessionStarted() }
979-
// The reload path is change-driven, not save-driven: any source of a
980-
// file change triggers it, including Termux, plugins and git.
981-
result.session.watcher.start(::onWatcherBatch)
982-
dispatch(SessionEvent.ProvisioningSucceeded(result.baselineGeneration))
975+
try {
976+
// A same-project predecessor's scratch tree can survive its teardown (see
977+
// [teardown]'s skip when a new session went live mid-shutdown); whatever it
978+
// retained belongs to another baseline and must not answer this session's
979+
// reconnects.
980+
result.session.retainedPayloads.clear()
981+
// The installed APK boots at the stamped baseline generation (concurrency.md
982+
// rule 2): the allocator must stay strictly above it, and adopting it as the
983+
// deploy tally makes a reconnect at the stamp read in-sync by construction.
984+
result.tracker.adoptAtLeast(result.baselineGeneration)
985+
result.session.lastDeployedGeneration = result.baselineGeneration
986+
// Build ids restart per session; give the sink its session boundary.
987+
report { metrics.onSessionStarted() }
988+
// The reload path is change-driven, not save-driven: any source of a
989+
// file change triggers it, including Termux, plugins and git.
990+
result.session.watcher.start(::onWatcherBatch)
991+
dispatch(SessionEvent.ProvisioningSucceeded(result.baselineGeneration))
992+
} catch (e: kotlinx.coroutines.CancellationException) {
993+
throw e
994+
} catch (e: Throwable) {
995+
// The runner's error boundary ends at its outcome; this tail (retention
996+
// IO, the persisted generation store, the FileObserver registration) is
997+
// the manager's half of the same assembly, and a throw here would escape
998+
// to a scope with no CoroutineExceptionHandler and crash CoGo with the
999+
// daemon up and the uid session registered. [live] is already set, so the
1000+
// failure effect's teardown unwinds both.
1001+
log.error("Installing the provisioned quick-build session threw", e)
1002+
dispatch(SessionEvent.ProvisioningFailed(QuickBuildMessage.Literal(e.message ?: e.javaClass.name)))
1003+
}
9831004
}
9841005
}
9851006
}
@@ -1268,13 +1289,29 @@ class QuickBuildSessionManager(
12681289
retained.generation,
12691290
)
12701291
val result =
1271-
deploy.deploy(
1272-
retained.generation,
1273-
retained.dexFile,
1274-
retained.arscFile,
1275-
retained.assetsZip,
1276-
retained.metadataJson,
1277-
)
1292+
try {
1293+
deploy.deploy(
1294+
retained.generation,
1295+
retained.dexFile,
1296+
retained.arscFile,
1297+
retained.assetsZip,
1298+
retained.metadataJson,
1299+
)
1300+
} catch (e: kotlinx.coroutines.CancellationException) {
1301+
throw e
1302+
} catch (e: Throwable) {
1303+
// deploy() is throw-capable (see notifyBuilding's guard), and this runs
1304+
// inside the reconnect collector launched once in [init]: an escaping
1305+
// throw would kill that collector for the rest of the process, and every
1306+
// later stale reconnect would run old code silently - the exact failure
1307+
// the collector exists to prevent. Contain it as a failed re-send.
1308+
log.warn(
1309+
"Re-send of retained generation {} threw; falling back to a catch-up build",
1310+
retained.generation,
1311+
e,
1312+
)
1313+
return false
1314+
}
12781315
if (result is DeployResult.Reloaded) return true
12791316
log.warn(
12801317
"Re-send of retained generation {} failed ({}); falling back to a catch-up build",

quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/Fakes.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,12 @@ class FakeDeploy : DeploySender {
150150
val resultQueue = ArrayDeque<DeployResult>()
151151
var disconnects: Boolean = true
152152

153+
/**
154+
* When set, every [deploy] throws it after recording the call. Stands in for a binder
155+
* edge the sender did not classify into a [DeployResult] - the contract is throw-capable.
156+
*/
157+
var deployError: Throwable? = null
158+
153159
/**
154160
* Generation the fake "relaunched app" reconnects at, given the last deployed
155161
* generation; return null for a relaunch that never reconnects. Defaults to a
@@ -165,6 +171,7 @@ class FakeDeploy : DeploySender {
165171
metadataJson: String,
166172
): DeployResult {
167173
calls += Call(generation, dexFile, arscFile, assetsZip, metadataJson)
174+
deployError?.let { throw it }
168175
return resultQueue.removeFirstOrNull() ?: result
169176
}
170177

quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManagerTest.kt

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,12 @@ class QuickBuildSessionManagerTest {
167167
/** Captures the watcher the manager builds so a test can push change batches. */
168168
private var watcher: FakeWatcher? = null
169169

170+
/**
171+
* When set, the manager-built watcher's [ProjectWatcher.start] throws it. Stands in for a
172+
* real FileObserver/inotify registration failure in provisioning's success tail.
173+
*/
174+
private var watcherStartError: (() -> Throwable)? = null
175+
170176
/**
171177
* Every request to bring the proxy app to the foreground, as (package, launcherActivity).
172178
* Behaviours 2/3/4 are exactly "is this list empty, and when did it grow", so it is the
@@ -196,13 +202,15 @@ class QuickBuildSessionManagerTest {
196202
*/
197203
private class FakeWatcher(
198204
private val filter: WatchFilter,
205+
private val startError: () -> Throwable? = { null },
199206
) : ProjectWatcher {
200207
private var onBatch: ((ChangedFiles.Known) -> Unit)? = null
201208

202209
/** Survives [stop]; see [emitRacingStop]. */
203210
private var lastOnBatch: ((ChangedFiles.Known) -> Unit)? = null
204211

205212
override fun start(onBatch: (ChangedFiles.Known) -> Unit) {
213+
startError()?.let { throw it }
206214
this.onBatch = onBatch
207215
this.lastOnBatch = onBatch
208216
}
@@ -349,7 +357,9 @@ class QuickBuildSessionManagerTest {
349357
}
350358
}
351359
},
352-
watcherFactory = { _, _, filter, _ -> FakeWatcher(filter).also { watcher = it } },
360+
watcherFactory = { _, _, filter, _ ->
361+
FakeWatcher(filter, { watcherStartError?.invoke() }).also { watcher = it }
362+
},
353363
metrics = recordingMetrics,
354364
warmCompileEnabled = warmCompileEnabled,
355365
nowMillis = nowMillis,
@@ -2220,6 +2230,39 @@ class QuickBuildSessionManagerTest {
22202230
assertThat(executed.last().forced).isTrue()
22212231
}
22222232

2233+
@Test
2234+
fun `a throwing re-send is contained - catch-up falls back now and stays alive for later reconnects`() =
2235+
runTest {
2236+
// deploy() is throw-capable, and the re-send runs inside the reconnect
2237+
// collector launched once in init: an escaping throw would kill that
2238+
// collector for the rest of the process, and every later stale reconnect
2239+
// would run old code silently - the exact failure it exists to prevent.
2240+
val manager = createManager()
2241+
manager.onQuickBuildTapped()
2242+
advanceUntilIdle()
2243+
manager.save(sourceFile)
2244+
advanceUntilIdle()
2245+
assertThat(executed).hasSize(1)
2246+
2247+
seedRetainedPayload(generation = 1L)
2248+
deploy.deployError = RuntimeException("binder transaction failed")
2249+
connections.onConnected(connectedAt(0))
2250+
advanceUntilIdle()
2251+
2252+
// Contained like any other failed re-send: attempted once, then the
2253+
// last-resort forced rebuild of current sources.
2254+
assertThat(deploy.calls).hasSize(1)
2255+
assertThat(executed).hasSize(2)
2256+
assertThat(executed.last().forced).isTrue()
2257+
2258+
// The collector survived: the next stale reconnect is still repaired.
2259+
deploy.deployError = null
2260+
connections.onConnected(connectedAt(0))
2261+
advanceUntilIdle()
2262+
assertThat(executed).hasSize(3)
2263+
assertThat(executed.last().forced).isTrue()
2264+
}
2265+
22232266
@Test
22242267
fun `retention from an older deploy is never replayed - the forced build repairs instead`() =
22252268
runTest {
@@ -3830,6 +3873,45 @@ class QuickBuildSessionManagerTest {
38303873
assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle())
38313874
}
38323875

3876+
@Test
3877+
fun `a session started after a prebuild-stop still gets its Gradle build cancelled on restart`() =
3878+
runTest {
3879+
// The Prebuilding stop above latches the cancel-issued flag with no teardown to
3880+
// clear it. Left stale, the NEXT session's teardown would skip the Gradle
3881+
// cancel, the orphaned build would keep the device's one build slot, and the
3882+
// user's "Restart session" would come back as a SlotBusy setup failure.
3883+
prebuildGate = CompletableDeferred()
3884+
val manager = createManager()
3885+
manager.prebuild()
3886+
advanceUntilIdle()
3887+
manager.onQuickBuildTapped()
3888+
advanceUntilIdle()
3889+
manager.onCancelRequested()
3890+
advanceUntilIdle()
3891+
assertThat(proxyAppBuildCancelCount).isEqualTo(1)
3892+
assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle())
3893+
prebuildGate!!.complete(Unit)
3894+
advanceUntilIdle()
3895+
3896+
// A fresh tap owns a fresh Gradle proxy app build...
3897+
provisionGate = CompletableDeferred()
3898+
manager.onQuickBuildTapped()
3899+
advanceUntilIdle()
3900+
assertThat(manager.state.value)
3901+
.isEqualTo(QuickBuildSessionState.Provisioning(userInitiated = true))
3902+
3903+
// ...so the restart's teardown must reach the Gradle cancel: nothing else
3904+
// releases the build slot for the reprovision it goes on to run.
3905+
manager.restartSessionAndReprovision()
3906+
advanceUntilIdle()
3907+
assertThat(proxyAppBuildCancelCount).isEqualTo(2)
3908+
3909+
// And the reprovision itself still lands.
3910+
provisionGate!!.complete(Unit)
3911+
advanceUntilIdle()
3912+
assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0))
3913+
}
3914+
38333915
@Test
38343916
fun `stopping during provisioning cancels the proxy app build and tears the session down`() =
38353917
runTest {
@@ -3856,6 +3938,33 @@ class QuickBuildSessionManagerTest {
38563938
assertThat(watcher).isNull()
38573939
}
38583940

3941+
@Test
3942+
fun `a watcher-start throw in provisioning's success tail fails the session instead of escaping`() =
3943+
runTest {
3944+
// The install tail after a successful provision (retention clear, generation
3945+
// adoption, watcher registration) runs on a scope with no
3946+
// CoroutineExceptionHandler: an escaping throw would crash CoGo with the
3947+
// daemon up and the uid session registered, and strand the machine in
3948+
// Provisioning.
3949+
watcherStartError = { IllegalStateException("inotify watch limit reached") }
3950+
val manager = createManager()
3951+
manager.onQuickBuildTapped()
3952+
advanceUntilIdle()
3953+
3954+
// Same path as any other failed provision: torn down clean to Idle with the
3955+
// error surfaced and the daemon down - never a crash or a wedged Provisioning.
3956+
assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Idle(lastStartFailed = true))
3957+
assertThat(userMessages).contains(QuickBuildMessage.Literal("inotify watch limit reached"))
3958+
assertThat(daemon.isRunning).isFalse()
3959+
3960+
// The next tap re-provisions from scratch - not wedged.
3961+
watcherStartError = null
3962+
manager.onQuickBuildTapped()
3963+
advanceUntilIdle()
3964+
assertThat(provisionCount).isEqualTo(2)
3965+
assertThat(manager.state.value).isEqualTo(QuickBuildSessionState.Ready(0))
3966+
}
3967+
38593968
/**
38603969
* A provision whose baseline declares [components] - the only fact the stale-helper
38613970
* warning keys on, since whether such a component is currently INSTANTIATED is unknowable

0 commit comments

Comments
 (0)