Skip to content

Commit 798ab4c

Browse files
fryanpanclaude
andcommitted
ADFA-4128: ping the proxy app before calling a restart-deploy timeout an outdated runtime
awaitDisconnect only watched the connection registry, which clears when linkToDeath fires. A loaded device can deliver that notification after the 5 s disconnect wait, and the deployer then reported a current runtime that had exited as one that "predates restart support" and forced a multi-minute Gradle rebuild. On timeout the channel now pings the registered binder. A ping is a transaction, so it fails as soon as the process is gone; the channel then clears the registry as the late notification would and reports the runtime gone, and the restart path proceeds to relaunch. A runtime that still answers keeps the outdated-runtime verdict. Review thread: #1718 (comment) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2
1 parent 804c743 commit 798ab4c

4 files changed

Lines changed: 89 additions & 10 deletions

File tree

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

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ interface DeploySender {
5858
*
5959
* @param timeoutMillis upper bound on the wait, sized for a runtime exit rather than a
6060
* process launch
61-
* @return true when disconnected within [timeoutMillis]
61+
* @return true when the runtime is gone within [timeoutMillis]: it disconnected, or its
62+
* process no longer answers although the death notification has not landed yet
6263
*/
6364
suspend fun awaitDisconnect(timeoutMillis: Long): Boolean
6465

@@ -227,14 +228,27 @@ class DeployChannel(
227228
}
228229
}
229230

230-
override suspend fun awaitDisconnect(timeoutMillis: Long): Boolean =
231+
override suspend fun awaitDisconnect(timeoutMillis: Long): Boolean {
231232
// The awaited value is null by construction, so the block must yield its own
232233
// non-null sentinel: returning `first { it == null }` would make a real
233234
// disconnect indistinguishable from a timeout.
234-
withTimeoutOrNull(timeoutMillis) {
235-
connections.target.first { it == null }
236-
true
237-
} == true
235+
val disconnected =
236+
withTimeoutOrNull(timeoutMillis) {
237+
connections.target.first { it == null }
238+
true
239+
} == true
240+
if (disconnected) return true
241+
// The registry clears on linkToDeath, which a loaded device can deliver well after
242+
// the process died. A ping is a transaction, so it fails as soon as the process is
243+
// gone; a runtime that is genuinely still running answers it. Without this, a late
244+
// notification reads as a runtime that ignored the restart and costs a rebuild.
245+
val connection = connections.target.value ?: return false
246+
val binder = connection.target.asBinder() ?: return false
247+
if (binder.pingBinder()) return false
248+
log.info("Proxy app process is gone; its death notification has not landed yet")
249+
connections.onDisconnected(binder)
250+
return true
251+
}
238252

239253
override suspend fun awaitReconnect(timeoutMillis: Long): Long? =
240254
withTimeoutOrNull(timeoutMillis) {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,9 @@ internal class PayloadDeployer(
188188
when (val result = recovered.result) {
189189
is DeployResult.Reloaded -> {
190190
if (!deploy.awaitDisconnect(restartDisconnectTimeoutMillis)) {
191-
// The runtime acked but kept running, so it predates restart support
192-
// and hot-swapped instead, leaving a live service possibly stale. A
193-
// proxy app rebuild reinstalls a current runtime.
191+
// The runtime acked but its process still answers, so it predates
192+
// restart support and hot-swapped instead, leaving a live service
193+
// possibly stale. A proxy app rebuild reinstalls a current runtime.
194194
return BuildOutcome.RequiresProxyAppRebuild(
195195
InvalidationReason.OUTDATED_BASELINE,
196196
"proxy app acknowledged a restart deploy but did not exit " +

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,42 @@ class DeployChannelWaitsTest {
4040

4141
private fun connect(generation: Long) = connections.onConnected(ConnectedTarget(target, "com.example.quickbuild", generation))
4242

43+
/**
44+
* A target whose binder answers [IBinder.pingBinder] as scripted. A reflection proxy,
45+
* because android.jar's [IBinder] is unmocked on the JVM and only the ping is live.
46+
*/
47+
private fun pingable(alive: Boolean): PingableTarget = PingableTarget(alive)
48+
49+
private class PingableTarget(
50+
alive: Boolean,
51+
) {
52+
private val binder: IBinder =
53+
java.lang.reflect.Proxy.newProxyInstance(
54+
IBinder::class.java.classLoader,
55+
arrayOf(IBinder::class.java),
56+
) { _, method, _ ->
57+
when (method.name) {
58+
"pingBinder" -> alive
59+
else -> null
60+
}
61+
} as IBinder
62+
63+
val target =
64+
object : IQuickBuildTarget {
65+
override fun asBinder(): IBinder = binder
66+
67+
override fun onPayload(
68+
generation: Long,
69+
dexPayload: ParcelFileDescriptor?,
70+
resourcesPayload: ParcelFileDescriptor?,
71+
assetsPayload: ParcelFileDescriptor?,
72+
metadataJson: String?,
73+
) = Unit
74+
75+
override fun onBuildStatus(statusJson: String?) = Unit
76+
}
77+
}
78+
4379
@Test
4480
fun `awaitDisconnect reports true when the proxy app actually disconnects`() =
4581
runTest {
@@ -69,6 +105,35 @@ class DeployChannelWaitsTest {
69105
assertThat(channel.awaitDisconnect(5_000)).isTrue()
70106
}
71107

108+
@Test
109+
fun `awaitDisconnect reports true on timeout when the process no longer answers a ping`() =
110+
runTest {
111+
// The death notification is late, not absent: the process is gone, so a ping
112+
// fails. Reporting false here would attribute the timeout to a runtime that
113+
// ignored the restart and cost a Gradle rebuild.
114+
val dead = pingable(alive = false)
115+
connections.onConnected(ConnectedTarget(dead.target, "com.example.quickbuild", 7))
116+
val awaited = async { channel.awaitDisconnect(5_000) }
117+
118+
advanceTimeBy(5_001)
119+
120+
assertThat(awaited.await()).isTrue()
121+
assertThat(connections.target.value).isNull()
122+
}
123+
124+
@Test
125+
fun `awaitDisconnect reports false on timeout when the process still answers a ping`() =
126+
runTest {
127+
val alive = pingable(alive = true)
128+
connections.onConnected(ConnectedTarget(alive.target, "com.example.quickbuild", 7))
129+
val awaited = async { channel.awaitDisconnect(5_000) }
130+
131+
advanceTimeBy(5_001)
132+
133+
assertThat(awaited.await()).isFalse()
134+
assertThat(connections.target.value).isNotNull()
135+
}
136+
72137
@Test
73138
fun `awaitReconnect returns the generation the fresh process reported`() =
74139
runTest {

‎quickbuild/docs/debugging.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ not, and changing those means editing the constant: `MODULE_SCAN_MAX_DEPTH`, `UI
337337
| Daemon request timeout | `DaemonProcessClient.DEFAULT_REQUEST_TIMEOUT_MILLIS` | 300 s | Per-request ceiling. Exceeding it fails that request and releases the slot; it does not by itself count as daemon death. |
338338
| Daemon shutdown grace | `DaemonProcessClient.SHUTDOWN_TIMEOUT_MILLIS` | 3 s | How long a polite `shutdown` is given before the child is killed. |
339339
| Deploy round trip | `DeployChannel.DEFAULT_TIMEOUT_MILLIS` | 15 s | One AIDL `onPayload` call. Exceeding it fails the deploy. |
340-
| Restart-deploy disconnect wait | `LiveReloadExecutorImpl.DEFAULT_RESTART_DISCONNECT_TIMEOUT_MILLIS` | 5 s | How long the host waits for the proxy app to exit after a restart deploy. A runtime that acked but kept running is treated as an outdated baseline and forces a proxy app rebuild. |
340+
| Restart-deploy disconnect wait | `LiveReloadExecutorImpl.DEFAULT_RESTART_DISCONNECT_TIMEOUT_MILLIS` | 5 s | How long the host waits for the proxy app to exit after a restart deploy. On timeout the host pings the binder, so a late death notification is not mistaken for a running app; a runtime that acked and still answers is treated as an outdated baseline and forces a proxy app rebuild. |
341341
| Restart-deploy reconnect wait | `LiveReloadExecutorImpl.DEFAULT_RESTART_RECONNECT_TIMEOUT_MILLIS` | 15 s | How long the host waits for the relaunched proxy app to rebind. |
342342
| Runtime rebind backoff floor | `QuickBuildClient.REBIND_MIN_DELAY_MS` | 1 s | First rebind delay inside the proxy app, doubled per failed attempt and reset on every successful connect. |
343343
| Runtime rebind backoff ceiling | `QuickBuildClient.REBIND_MAX_DELAY_MS` | 30 s | Ceiling for that doubling, so a CoGo that never comes back costs one attempt per 30 s. |

0 commit comments

Comments
 (0)