Skip to content

Commit 9d2b1e8

Browse files
fryanpanclaude
andcommitted
ADFA-4128: qb-06 review fixes - latching union on failed builds, t3 before retention, broad binder catch, doc corrections
Akash's 08-31 review of #1718, all seven items: - LiveReloadOrchestrator: the failed-build merge goes through unionPendingLocked, so an Unknown batch collapsing over a mid-build invalidating edit latches the Gradle verdict instead of erasing it. Test pins the collapse (verified red: the manifest edit rode the fast daemon path). - PayloadDeployer: t3 is read before the retention copy (hot swap) and the retention clear (restart), keeping post-deploy bookkeeping out of the timed save-to-live span. Ordering tests verified red against the old order at both sites. - DeployChannel: the payload send rethrows CancellationException and degrades any other exception to DeployResult.Failed, mirroring notifyBuildStatus's binder rationale; escape would also dodge the not-connected escalation. Test verified red. - deploy/README: table gains ProxyAppPriorityHold and RetainedPayloadStore. - ComponentInfo: header rewritten to the declares-one-always-restarts rule; supertypes marked carried-but-unread. - ClassHeader: KDoc aligned with the README's "currently unused". - E2eTimelineRecorder: false class-header-parse claim dropped. quickbuild:core tests green (both flavors). Also: plain-language pass over the comments added by these fixes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STCsdMzx9daNBcqMN424Ci
1 parent 32ec95d commit 9d2b1e8

10 files changed

Lines changed: 168 additions & 15 deletions

File tree

‎quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/ClassHeader.kt‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import java.io.DataInputStream
44

55
/**
66
* The hierarchy facts of one compiled class file - name, superclass, directly implemented
7-
* interfaces - which is what keeps [DeployPolicy]'s supertype index current across builds.
7+
* interfaces. Currently unused: [DeployPolicy] keeps no supertype index. Kept for a planned
8+
* deploy mode that redefines classes in place and will need the hierarchy.
89
*
910
* Parsed by a constant-pool walk rather than a bytecode library; these fields sit right after
1011
* the constant pool, so nothing past the interface list is read. Names are in dot form with

‎quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/ComponentInfo.kt‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,20 @@ package org.appdevforall.cotg.quickbuild.domain.reload
33
/**
44
* Kind of a manifest component the proxy app build recorded (setup.json `components`).
55
*
6-
* The restart closure referred to throughout this file is [DeployPolicy]'s: a
7-
* restart-sensitive component class plus its user-side supertypes and their nested classes,
8-
* any recompile of which forces a proxy-app process restart.
6+
* The restart rule referred to throughout this file is [DeployPolicy]'s: a manifest that
7+
* declares even one restart-sensitive component makes EVERY deploy a process restart. The
8+
* policy does not look at what the compile touched, so there is no per-build set of
9+
* affected classes to compute; "outside the restart rule" below means the kind never makes
10+
* a deploy restart-sensitive on its own.
911
*/
1012
enum class ComponentKind {
11-
/** An `<activity>`; outside the restart closure, since recreate already refreshes it. */
13+
/** An `<activity>`; outside the restart rule, since recreate already refreshes it. */
1214
ACTIVITY,
1315

1416
/** A `<service>`; a live instance cannot be swapped, so it forces a process restart. */
1517
SERVICE,
1618

17-
/** A `<receiver>`; outside the restart closure, being instantiated fresh per delivery. */
19+
/** A `<receiver>`; outside the restart rule, being instantiated fresh per delivery. */
1820
RECEIVER,
1921

2022
/** A `<provider>`; like a service, a live instance forces a process restart. */
@@ -83,7 +85,9 @@ fun ComponentInfo.isRestartSensitive(): Boolean = kind in RESTART_SENSITIVE_KIND
8385
* @property launcher true for the launcher activity - its [proxyClass] is the explicit
8486
* relaunch target after a restart-deploy.
8587
* @property supertypes the user-side (project-compiled) superclass chain recorded from
86-
* class headers at proxy app build time; seeds the restart closure's supertype index.
88+
* class headers at proxy app build time. Carried but currently unread: [DeployPolicy]
89+
* keeps no supertype index. Kept for a planned deploy mode that redefines classes in
90+
* place and will need the hierarchy.
8791
*/
8892
data class ComponentInfo(
8993
val kind: ComponentKind,

‎quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestrator.kt‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -712,7 +712,10 @@ class LiveReloadOrchestrator(
712712

713713
else -> {
714714
val newSavesArrivedMidBuild = !pending.isEmpty || pendingForced
715-
pending = flight.batch + pending
715+
// unionPendingLocked, not bare plus: if flight.batch is Unknown (built off an
716+
// untrusted baseline), plus would collapse the union to Unknown and lose the
717+
// full-Gradle-build verdict of a manifest or gradle edit that landed mid-build.
718+
pending = unionPendingLocked(flight.batch, pending)
716719
// The dead attempt's t0 must not outlive it: its batch is back in pending but
717720
// waiting on the user, not queueing, which is not latency this loop owes. A
718721
// save that landed MID-build did genuinely queue behind this one, so its

‎quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannel.kt‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package org.appdevforall.cotg.quickbuild.service.deploy
22

33
import android.os.ParcelFileDescriptor
44
import android.os.RemoteException
5+
import kotlinx.coroutines.CancellationException
56
import kotlinx.coroutines.CoroutineStart
67
import kotlinx.coroutines.async
78
import kotlinx.coroutines.coroutineScope
@@ -193,6 +194,18 @@ class DeployChannel(
193194
verdict.cancel()
194195
log.error("Deploy of generation {} could not open a payload fd", generation, e)
195196
return@coroutineScope DeployResult.Failed("Cannot open payload: ${e.message}")
197+
} catch (e: CancellationException) {
198+
throw e
199+
} catch (e: Exception) {
200+
// A binder proxy can throw more than RemoteException (see
201+
// notifyBuildStatus below). The throw must come back as a failed
202+
// deploy: escaping would crash the deploy loop, and it would bypass
203+
// the orchestrator's repeated-failure tally
204+
// (LiveReloadOrchestrator.recordFailureLocked), which only sees
205+
// failures returned as results.
206+
verdict.cancel()
207+
log.error("Deploy of generation {} failed past the binder surface", generation, e)
208+
return@coroutineScope DeployResult.Failed("Deploy failed: ${e.message}")
196209
}
197210

198211
when (val report = verdict.await()) {

‎quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,15 @@ internal class PayloadDeployer(
126126
deployRecovering(generation, dexFile, arscFile, assets?.zip, metadata(restart = false))
127127
return when (val result = recovered.result) {
128128
is DeployResult.Reloaded -> {
129-
// The app confirmed the payload, so these bytes are worth retaining for
130-
// the reconnect re-send (concurrency.md rules 3-4).
131-
retention?.retain(generation, dexFile, arscFile, assets?.zip, metadata(restart = false))
132129
// t3: reportReloaded came back from the recreated activity's onResume,
133130
// so the new code is live. One clock read feeds both, or the reported
134131
// duration would run past the timeline's own total for the same loop.
132+
// Read BEFORE the retention copy below: the copy is post-deploy
133+
// bookkeeping, not save-to-live latency, so it stays out of the timed span.
135134
val liveAt = clock()
135+
// The app confirmed the payload, so these bytes are worth retaining for
136+
// the reconnect re-send (concurrency.md rules 3-4).
137+
retention?.retain(generation, dexFile, arscFile, assets?.zip, metadata(restart = false))
136138
reportTimeline(recorder.completed(generation, liveAt))
137139
BuildOutcome.Success(generation, liveAt - loopStartedAt)
138140
}
@@ -265,11 +267,12 @@ internal class PayloadDeployer(
265267
// re-send has no relaunch behind it, so the app would exit and stay closed.
266268
// A below-deployed reconnect after this deploy falls back to the forced
267269
// catch-up rebuild, which re-derives the route.
268-
retention?.clear()
269270
// t3: the relaunched process reconnected at the deployed generation, so
270271
// the restart swap is live. Slower than a hot swap by a full process
271-
// launch. One clock read feeds both, as on the hot-swap path.
272+
// launch. One clock read feeds both, as on the hot-swap path - and read
273+
// BEFORE the retention drop below, which is bookkeeping outside the span.
272274
val liveAt = clock()
275+
retention?.clear()
273276
reportTimeline(recorder.completed(generation, liveAt))
274277
BuildOutcome.Success(generation, liveAt - loopStartedAt, restarted = true)
275278
}

‎quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ This folder holds the deploy side of the service layer: the exported host servic
99
| [`DeployChannel.kt`](DeployChannel.kt) | The on-device `DeploySender`: passes payload files as read-only fds over the oneway `onPayload`, awaits the matching report, and bounds every wait. |
1010
| [`PayloadDeployer.kt`](PayloadDeployer.kt) | Routes a build's artifacts to hot swap vs process restart, handles relaunch/reconnect and the no-app retry, allocates generations, and maps each `DeployResult` to a `BuildOutcome`. |
1111
| [`BuildStatusJson.kt`](BuildStatusJson.kt) | Builds the string-valued `statusJson` for `onBuildStatus` (building, build_ok, build_failed, reinstall_pending) the proxy app's overlay reads. |
12+
| [`ProxyAppPriorityHold.kt`](ProxyAppPriorityHold.kt) | Keeps the connected proxy app out of the cached-app freezer by binding its keep-alive service; `BoundServicePriorityHold` is the on-device implementation. |
13+
| [`RetainedPayloadStore.kt`](RetainedPayloadStore.kt) | Retains the last confirmed deploy's bytes so a reconnect below the deployed generation can be answered by a re-send instead of a forced rebuild. |

‎quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/E2eTimelineRecorder.kt‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,7 @@ internal class E2eTimelineRecorder(
7373
}
7474

7575
/**
76-
* Records the hot-swap-versus-restart decision, including the class-header parses it
77-
* needs.
76+
* Records the hot-swap-versus-restart decision.
7877
*
7978
* @param millis host-observed span
8079
*/

‎quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestratorTest.kt‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2202,4 +2202,31 @@ class LiveReloadOrchestratorTest {
22022202
assertThat(executor.requests.last().route).isEqualTo(BuildRoute.CodeAndResources)
22032203
assertThat(events.filterIsInstance<OrchestratorEvent.InvalidationRequired>()).isEmpty()
22042204
}
2205+
2206+
@Test
2207+
fun `a manifest edit landing mid-build survives a failed build collapsing the set to Unknown`() =
2208+
runTest {
2209+
// A failed build hands its batch back into pending. When that batch is Unknown
2210+
// (built off an untrusted baseline) and a manifest edit landed mid-build, a bare
2211+
// plus collapses the union to Unknown and loses the manifest edit's full-build
2212+
// verdict - the next save then takes the fast daemon path with a change the
2213+
// daemon cannot absorb.
2214+
val executor = GatedExecutor()
2215+
val events = mutableListOf<OrchestratorEvent>()
2216+
val orchestrator = LiveReloadOrchestrator(executor, ChangeClassifier(), backgroundScope) { events += it }
2217+
2218+
orchestrator.onBaselineUntrusted()
2219+
orchestrator.onFilesChanged(known(srcA))
2220+
runCurrent()
2221+
orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml"))
2222+
runCurrent()
2223+
executor.finish(0, compileError())
2224+
runCurrent()
2225+
orchestrator.onFilesChanged(known(srcB))
2226+
runCurrent()
2227+
2228+
assertThat(executor.requests).hasSize(1)
2229+
assertThat(events.filterIsInstance<OrchestratorEvent.InvalidationRequired>())
2230+
.containsExactly(OrchestratorEvent.InvalidationRequired(InvalidationReason.MANIFEST_CHANGED))
2231+
}
22052232
}

‎quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannelDeployTest.kt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,21 @@ class DeployChannelDeployTest {
159159
assertThat((result as DeployResult.Failed).message).contains("Cannot open payload")
160160
}
161161

162+
@Test
163+
fun `a payload throw beyond RemoteException degrades to Failed instead of escaping`() =
164+
runTest {
165+
// A binder proxy can throw more than RemoteException (the reason notifyBuildStatus
166+
// catches Exception). An escape here would crash the deploy loop instead of
167+
// failing the one deploy, and would bypass the orchestrator's repeated-failure
168+
// tally, which only sees failures returned as results.
169+
connect(ScriptedTarget(onPayloadThrow = { IllegalStateException("binder went weird") }))
170+
171+
val result = channel.deploy(3, null, null, null, "{}")
172+
173+
assertThat(result).isInstanceOf(DeployResult.Failed::class.java)
174+
assertThat((result as DeployResult.Failed).message).contains("Deploy failed")
175+
}
176+
162177
@Test
163178
fun `a proxy app that never answers times out with the configured timeout`() =
164179
runTest {

‎quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployerRetentionTest.kt‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,92 @@ class PayloadDeployerRetentionTest {
152152
assertThat(store.load()).isNull()
153153
}
154154

155+
@Test
156+
fun `the retention copy runs outside the timed save-to-live span`() =
157+
runTest {
158+
// The live-at clock read (t3) happens before retain(): the copy is post-deploy
159+
// bookkeeping, and a clock read after it would bill the copy - which scales with
160+
// app size, not edit size - to every successful deploy's save-to-live latency.
161+
var clockReadAfterRetention = false
162+
val deployer =
163+
PayloadDeployer(
164+
deploy = deploy,
165+
generations = GenerationTracker(MemoryGenerationStore()),
166+
entryActivity = "com.example.app.MainActivity",
167+
proxyAppPackage = "com.example.app",
168+
launcherActivity = "com.example.app.Proxy0Activity",
169+
launcher = ProxyAppLauncher { _, _ -> true },
170+
restartDisconnectTimeoutMillis = 5_000,
171+
restartReconnectTimeoutMillis = 15_000,
172+
clock = {
173+
if (store.load() != null) clockReadAfterRetention = true
174+
1_000
175+
},
176+
reportTimeline = {},
177+
retention = store,
178+
)
179+
180+
deployer.deploy(
181+
DeployDecision.Recreate,
182+
artifact("built.dex", "dex-bytes"),
183+
null,
184+
null,
185+
loopStartedAt = 0,
186+
recorder = recorder(),
187+
)
188+
189+
assertThat(store.load()).isNotNull()
190+
assertThat(clockReadAfterRetention).isFalse()
191+
}
192+
193+
@Test
194+
fun `the restart path's retention clear also runs outside the timed span`() =
195+
runTest {
196+
// Same ordering rule as the hot-swap path: the clear is bookkeeping, so t3 is
197+
// read while the previous deploy's bytes are still retained.
198+
var tracking = false
199+
var clockReadAfterClear = false
200+
val deployer =
201+
PayloadDeployer(
202+
deploy = deploy,
203+
generations = GenerationTracker(MemoryGenerationStore()),
204+
entryActivity = "com.example.app.MainActivity",
205+
proxyAppPackage = "com.example.app",
206+
launcherActivity = "com.example.app.Proxy0Activity",
207+
launcher = ProxyAppLauncher { _, _ -> true },
208+
restartDisconnectTimeoutMillis = 5_000,
209+
restartReconnectTimeoutMillis = 15_000,
210+
clock = {
211+
if (tracking && store.load() == null) clockReadAfterClear = true
212+
1_000
213+
},
214+
reportTimeline = {},
215+
retention = store,
216+
)
217+
deployer.deploy(
218+
DeployDecision.Recreate,
219+
artifact("built.dex", "gen-1-dex"),
220+
null,
221+
null,
222+
loopStartedAt = 0,
223+
recorder = recorder(),
224+
)
225+
assertThat(store.load()).isNotNull()
226+
tracking = true
227+
228+
deployer.deploy(
229+
DeployDecision.Restart(ComponentKind.SERVICE, "com.example.app.SyncService"),
230+
artifact("built.dex", "gen-2-dex"),
231+
null,
232+
null,
233+
loopStartedAt = 0,
234+
recorder = recorder(),
235+
)
236+
237+
assertThat(store.load()).isNull()
238+
assertThat(clockReadAfterClear).isFalse()
239+
}
240+
155241
@Test
156242
fun `a restart deploy whose relaunch never comes back retains nothing`() =
157243
runTest {

0 commit comments

Comments
 (0)