Skip to content

Commit b746ab5

Browse files
fryanpanclaude
andcommitted
ADFA-4128: qb 06 review fixes — binder-death handling, reconnect atomicity, deploy teardown
- Swallowed linkToDeath failure -> a binder dead at connect is reported as an instant death and never registered, so deploys fail fast as NotConnected instead of timing out with the freezer hold kept on a dead package (tests: "a binder that is dead at connect is not left registered", "a dead binder's stale connect retry does not clobber a live registration"). - Non-atomic connect watch/registration -> registration and death watch are one @synchronized step, and death delivery shares the lock, so the watched binder and the registered target can never disagree and a death cannot slip between link and registration (test: "a death delivered while connect is registering still clears the target"; wiring: "a reconnect moves the watch, and firing it clears the registration"). - Restart payload retained with hot-swap metadata -> a confirmed restart deploy clears the retained set instead of retaining it, so a reconnect catch-up can never hot-swap over the live restart-sensitive component and falls back to the forced rebuild (test: "a confirmed restart deploy clears the retained payload instead of retaining it"). - endSession leaving an in-flight deploy to time out -> endSession routes through onDisconnected, whose Disconnected report answers the waiter deterministically (test: "ending the session answers a deploy awaiting its verdict as Disconnected"). - Adjacent minor: disconnect() now unlinks the death watch, so no stale recipient outlives a graceful disconnect (test: "a graceful disconnect unlinks the death watch"). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W
1 parent 90730a7 commit b746ab5

7 files changed

Lines changed: 237 additions & 36 deletions

File tree

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -257,9 +257,15 @@ internal class PayloadDeployer(
257257
}
258258

259259
else -> {
260-
// Retained with hot-swap metadata, not this deploy's restart flag: a
261-
// reconnect catch-up must not ask the just-relaunched app to exit again.
262-
retention?.retain(generation, dexFile, arscFile, assets?.zip, metadata(restart = false))
260+
// Retain nothing, and drop what is retained: the reconnect catch-up replays
261+
// retained bytes as a hot swap, and hot-swapping a code-bearing payload onto
262+
// a process holding the live component this deploy restarted for recreates
263+
// the cross-loader ClassCastException the restart existed to prevent (see
264+
// DeployPolicy). Retaining it with the restart flag is no better - the
265+
// re-send has no relaunch behind it, so the app would exit and stay closed.
266+
// A below-deployed reconnect after this deploy falls back to the forced
267+
// catch-up rebuild, which re-derives the route.
268+
retention?.clear()
263269
// t3: the relaunched process reconnected at the deployed generation, so
264270
// the restart swap is live. Slower than a hot swap by a full process
265271
// launch. One clock read feeds both, as on the hot-swap path.

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,12 @@ class ProxyAppConnections {
9191
fun endSession() {
9292
expectedPackage = null
9393
expectedUid = null
94-
_target.value = null
95-
// Nothing can deploy to the app now, so stop exempting it from the freezer: a hold
96-
// kept past session end would cost the user battery on an app they are just running.
97-
priorityHold?.release()
94+
// Routed through [onDisconnected] so its Disconnected report answers a deploy still
95+
// awaiting its verdict - the session's end settles that deploy now, instead of
96+
// leaving it to ride out its full timeout against a target that is already gone.
97+
// The unconditional drop also releases the freezer hold: a hold kept past session
98+
// end would cost the user battery on an app they are just running.
99+
onDisconnected()
98100
}
99101

100102
/**

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

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,8 @@ class QuickBuildHostService : Service() {
6565
throw SecurityException("connect() with null target or packageName")
6666
}
6767

68-
watchForDeath(target.asBinder())
69-
7068
log.info("Proxy app {} connected at generation {}", packageName, runningGeneration)
71-
connections.onConnected(ConnectedTarget(target, packageName, runningGeneration))
69+
register(ConnectedTarget(target, packageName, runningGeneration))
7270
}
7371

7472
override fun reportReloaded(
@@ -88,30 +86,70 @@ class QuickBuildHostService : Service() {
8886
}
8987

9088
/**
91-
* Points the death watch at [binder], dropping the watch a superseded process left.
89+
* Registers [connection] and points the death watch at its binder, as one atomic step.
9290
*
9391
* Clearing the registration on death is what makes a deploy into a dead proxy app fail
9492
* fast as NotConnected instead of timing out on its binder. The unlink matters because a
9593
* recipient would otherwise accumulate one per reconnect, and the binder is passed on so
9694
* a late death from a superseded process cannot wipe the live registration.
9795
*
98-
* @param binder the connecting target's binder; null only for a local (non-binder) target,
99-
* which cannot die out from under us and so needs no watch
96+
* One synchronized step, not watch-then-register: two racing connects could otherwise
97+
* interleave so the registered target and the watched binder disagree, and a dead target
98+
* would then stay registered with no death notification ever coming. [onBinderDeath]
99+
* shares the lock for the same reason, so a death delivered while a connect is
100+
* mid-registration lands after the registration it must clear, never before it.
100101
*/
101102
@Synchronized
102-
private fun watchForDeath(binder: IBinder?) {
103-
if (binder == null) return
103+
private fun register(connection: ConnectedTarget) {
104+
// Null only for a local (non-binder) target, which cannot die out from under us.
105+
val binder = connection.target.asBinder()
106+
if (binder == null) {
107+
clearDeathWatch()
108+
connections.onConnected(connection)
109+
return
110+
}
111+
val recipient = IBinder.DeathRecipient { onBinderDeath(binder) }
112+
try {
113+
binder.linkToDeath(recipient, 0)
114+
} catch (e: Exception) {
115+
// Already dead at connect time: registering it would hold a dead target no
116+
// death notification can ever clear, so every deploy would ride out its
117+
// timeout with the freezer hold kept on a dead package. Report an instant
118+
// death instead, and keep any previous watch - the superseded-binder guard
119+
// in [ProxyAppConnections.onDisconnected] protects a still-live registration.
120+
log.warn("Proxy app {} died before its connect completed", connection.packageName, e)
121+
connections.onDisconnected(binder)
122+
return
123+
}
124+
clearDeathWatch()
125+
deathWatch = binder to recipient
126+
connections.onConnected(connection)
127+
}
128+
129+
/**
130+
* Handles a watched binder's death. Shares [register]'s lock so a death cannot be
131+
* consumed between the link and the registration it should clear.
132+
*/
133+
@Synchronized
134+
private fun onBinderDeath(binder: IBinder) {
135+
connections.onDisconnected(binder)
136+
}
137+
138+
/** Drops the current death watch, unlinking its recipient. */
139+
@Synchronized
140+
private fun clearDeathWatch() {
104141
deathWatch?.let { (previous, recipient) ->
105142
runCatching { previous.unlinkToDeath(recipient, 0) }
106143
}
107-
val recipient = IBinder.DeathRecipient { connections.onDisconnected(binder) }
108-
deathWatch = binder to recipient
109-
runCatching { binder.linkToDeath(recipient, 0) }
144+
deathWatch = null
110145
}
111146

112147
override fun disconnect(packageName: String?) {
113148
enforceCaller("disconnect")
114149
log.info("Proxy app {} disconnected", packageName)
150+
// Unlink too: a stale recipient would otherwise fire on the process's eventual
151+
// death and report a disconnect against whatever is registered by then.
152+
clearDeathWatch()
115153
connections.onDisconnected()
116154
}
117155

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ internal class RetainedPayloadStore(
2929
* @property generation the generation the deploy claimed; a re-send replays it unchanged,
3030
* and the runtime's strictly-newer gate accepts it because the reconnected app runs
3131
* something older
32-
* @property metadataJson metadata for the re-send; always the hot-swap variant, since a
33-
* reconnect catch-up must not ask the just-relaunched app to persist and exit again
32+
* @property metadataJson metadata for the re-send; always the hot-swap variant, because
33+
* only hot-swap deploys are retained - a restart deploy [clear]s the store instead,
34+
* since replaying its payload as a hot swap would land on the live restart-sensitive
35+
* component the restart existed to protect
3436
* @property dexFile the retained classes, or null when the deploy carried none
3537
* @property arscFile the retained resource APK, or null when the deploy carried none
3638
* @property assetsZip the retained changed-assets zip, or null when the deploy carried none
@@ -117,8 +119,9 @@ internal class RetainedPayloadStore(
117119
}
118120

119121
/**
120-
* Drops the retained set. Call whenever the baseline changes: the old baseline's bytes
121-
* must never be replayed onto a new one.
122+
* Drops the retained set. Call whenever the baseline changes - the old baseline's bytes
123+
* must never be replayed onto a new one - and after a confirmed restart deploy, whose
124+
* generation supersedes the retained one but must never be replayed as a hot swap.
122125
*/
123126
fun clear() {
124127
dir.deleteRecursively()

‎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
@@ -122,6 +122,21 @@ class DeployChannelDeployTest {
122122
assertThat(deploy.await()).isEqualTo(DeployResult.Disconnected)
123123
}
124124

125+
@Test
126+
fun `ending the session answers a deploy awaiting its verdict as Disconnected`() =
127+
runTest {
128+
val target = ScriptedTarget()
129+
connect(target)
130+
131+
val deploy = async { channel.deploy(3, null, null, null, "{}") }
132+
runCurrent()
133+
connections.endSession()
134+
135+
// Session teardown settles the in-flight deploy now; without the Disconnected
136+
// report it would ride out the full 5 s timeout and read as TimedOut.
137+
assertThat(deploy.await()).isEqualTo(DeployResult.Disconnected)
138+
}
139+
125140
@Test
126141
fun `a binder failure during onPayload reports Failed naming the binder`() =
127142
runTest {

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

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ import org.junit.jupiter.api.io.TempDir
1616
import java.io.File
1717

1818
/**
19-
* Retention side of [PayloadDeployer] (concurrency.md rules 3-4): a deploy the proxy app
20-
* confirmed leaves its bytes in the [RetainedPayloadStore] for the reconnect re-send, and an
21-
* unconfirmed one leaves the store exactly as it was.
19+
* Retention side of [PayloadDeployer] (concurrency.md rules 3-4): a confirmed hot-swap
20+
* deploy leaves its bytes in the [RetainedPayloadStore] for the reconnect re-send, an
21+
* unconfirmed one leaves the store exactly as it was, and a confirmed restart deploy clears
22+
* it - its payload must never be replayed as a hot swap.
2223
*/
2324
class PayloadDeployerRetentionTest {
2425
@TempDir lateinit var workDir: File
@@ -120,26 +121,35 @@ class PayloadDeployerRetentionTest {
120121
}
121122

122123
@Test
123-
fun `a confirmed restart deploy retains hot-swap metadata, not the restart flag`() =
124+
fun `a confirmed restart deploy clears the retained payload instead of retaining it`() =
124125
runTest {
125-
deploy.result = DeployResult.Reloaded(40)
126+
val deployer = deployer()
127+
deployer.deploy(
128+
DeployDecision.Recreate,
129+
artifact("built.dex", "gen-1-dex"),
130+
null,
131+
null,
132+
loopStartedAt = 0,
133+
recorder = recorder(),
134+
)
135+
assertThat(store.load()).isNotNull()
126136

127137
val outcome =
128-
deployer().deploy(
138+
deployer.deploy(
129139
DeployDecision.Restart(ComponentKind.SERVICE, "com.example.app.SyncService"),
130-
artifact("built.dex", "dex-bytes"),
140+
artifact("built.dex", "gen-2-dex"),
131141
null,
132142
null,
133143
loopStartedAt = 0,
134144
recorder = recorder(),
135145
)
136146

137147
assertThat(outcome).isInstanceOf(BuildOutcome.Success::class.java)
138-
val retained = store.load()!!
139-
// The deploy itself carried restart=true; the re-send must not, or a reconnect
140-
// catch-up would ask the just-relaunched app to persist and exit again.
141-
assertThat(retained.metadataJson).doesNotContain("restart")
142-
assertThat(retained.dexFile!!.readText()).isEqualTo("dex-bytes")
148+
// A reconnect catch-up replays retained bytes as a hot swap, which would land on
149+
// the live service this deploy restarted for and redefine its classes under it -
150+
// the cross-loader CCE the restart existed to prevent. Nothing may stay retained;
151+
// a below-deployed reconnect falls back to the forced catch-up rebuild.
152+
assertThat(store.load()).isNull()
143153
}
144154

145155
@Test

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

Lines changed: 129 additions & 2 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.IBinder
44
import android.os.ParcelFileDescriptor
5+
import android.os.RemoteException
56
import com.google.common.truth.Truth.assertThat
67
import com.itsaky.androidide.quickbuild.IQuickBuildTarget
78
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -14,8 +15,9 @@ import org.junit.jupiter.api.Test
1415

1516
/**
1617
* The uid trust boundary of [QuickBuildHostService.HostBinder], against a real
17-
* [ProxyAppConnections]. On the JVM the stubbed `Binder.getCallingUid()` reports uid 0,
18-
* so a session begun for uid 0 stands in for the matching proxy app and any other
18+
* [ProxyAppConnections], and the death-watch wiring that keeps the registered target and
19+
* the watched binder in step. On the JVM the stubbed `Binder.getCallingUid()` reports
20+
* uid 0, so a session begun for uid 0 stands in for the matching proxy app and any other
1921
* `expectedUid` stands in for a foreign caller.
2022
*/
2123
@OptIn(ExperimentalCoroutinesApi::class)
@@ -160,6 +162,131 @@ class QuickBuildHostBinderTest {
160162
assertThat(connections.target.value).isNull()
161163
}
162164

165+
@Test
166+
fun `a binder that is dead at connect is not left registered`() {
167+
beginMatchingSession()
168+
val dead = WatchableBinder(onLink = { _, _ -> throw RemoteException("already dead") })
169+
170+
binder.connect(targetOn(dead.binder), "com.example.quickbuild", 0)
171+
172+
// A dead target no death notification can ever clear would turn every deploy into
173+
// a full timeout; the failed link must fail fast to no registration instead.
174+
assertThat(connections.target.value).isNull()
175+
}
176+
177+
@Test
178+
fun `a dead binder's stale connect retry does not clobber a live registration`() {
179+
beginMatchingSession()
180+
val live = WatchableBinder()
181+
binder.connect(targetOn(live.binder), "com.example.quickbuild", 0)
182+
val dead = WatchableBinder(onLink = { _, _ -> throw RemoteException("already dead") })
183+
184+
binder.connect(targetOn(dead.binder), "com.example.quickbuild", 0)
185+
186+
// The superseded process's retry lost the race to the fresh process's bind; the
187+
// fresh registration must survive it, and stay watched.
188+
assertThat(
189+
connections.target.value
190+
?.target
191+
?.asBinder(),
192+
).isSameInstanceAs(live.binder)
193+
assertThat(live.watching()).hasSize(1)
194+
}
195+
196+
@Test
197+
fun `a death delivered while connect is registering still clears the target`() {
198+
beginMatchingSession()
199+
// linkToDeath delivers the death on another thread immediately, the way a proxy app
200+
// crashing right after its connect() call does. The helper returns once that
201+
// delivery has either completed or parked against the binder's registration lock,
202+
// so both orderings are exercised deterministically rather than raced.
203+
var death: Thread? = null
204+
val dying =
205+
WatchableBinder(
206+
onLink = { _, recipient ->
207+
val delivery = Thread { recipient.binderDied() }.also { it.start() }
208+
death = delivery
209+
while (delivery.state != Thread.State.TERMINATED && delivery.state != Thread.State.BLOCKED) {
210+
Thread.sleep(1)
211+
}
212+
},
213+
)
214+
215+
binder.connect(targetOn(dying.binder), "com.example.quickbuild", 0)
216+
death!!.join(5_000)
217+
218+
// Registration and death watch are one atomic step, so the death lands after the
219+
// registration and clears it - a dead target must never stay registered.
220+
assertThat(connections.target.value).isNull()
221+
}
222+
223+
@Test
224+
fun `a reconnect moves the watch, and firing it clears the registration`() {
225+
beginMatchingSession()
226+
val first = WatchableBinder()
227+
val second = WatchableBinder()
228+
binder.connect(targetOn(first.binder), "com.example.quickbuild", 0)
229+
binder.connect(targetOn(second.binder), "com.example.quickbuild", 1)
230+
231+
assertThat(first.watching()).isEmpty()
232+
val recipient = second.watching().single()
233+
234+
recipient.binderDied()
235+
236+
assertThat(connections.target.value).isNull()
237+
}
238+
239+
@Test
240+
fun `a graceful disconnect unlinks the death watch`() {
241+
beginMatchingSession()
242+
val live = WatchableBinder()
243+
binder.connect(targetOn(live.binder), "com.example.quickbuild", 0)
244+
245+
binder.disconnect("com.example.quickbuild")
246+
247+
// No stale recipient stays linked to fire on the process's eventual death.
248+
assertThat(live.watching()).isEmpty()
249+
}
250+
251+
/**
252+
* An [IBinder] that records link/unlink traffic and can script [IBinder.linkToDeath],
253+
* so the death-watch wiring is exercised for real. Only the two death-watch methods
254+
* are live; everything else no-ops through the reflection proxy.
255+
*/
256+
private class WatchableBinder(
257+
private val onLink: (WatchableBinder, IBinder.DeathRecipient) -> Unit = { _, _ -> },
258+
) {
259+
val linked = mutableListOf<IBinder.DeathRecipient>()
260+
val unlinked = mutableListOf<IBinder.DeathRecipient>()
261+
262+
val binder: IBinder =
263+
java.lang.reflect.Proxy.newProxyInstance(
264+
IBinder::class.java.classLoader,
265+
arrayOf(IBinder::class.java),
266+
) { _, method, args ->
267+
when (method.name) {
268+
"linkToDeath" -> {
269+
val recipient = args!![0] as IBinder.DeathRecipient
270+
onLink(this, recipient)
271+
linked += recipient
272+
null
273+
}
274+
275+
"unlinkToDeath" -> {
276+
unlinked += args!![0] as IBinder.DeathRecipient
277+
true
278+
}
279+
280+
else -> {
281+
null
282+
}
283+
}
284+
} as IBinder
285+
286+
/** The recipients still linked, in link order. */
287+
fun watching(): List<IBinder.DeathRecipient> = linked.filterNot { it in unlinked }
288+
}
289+
163290
/**
164291
* A distinct [IBinder] identity. Only reference identity is exercised, so a reflection
165292
* proxy is enough and avoids stubbing the whole interface against an unmocked android.jar.

0 commit comments

Comments
 (0)