fix(sidecar): cap stability poll independently of settle budget

The previous shape halved durationMillis between waitForAppToSettle
and the structural poll, then hammered hierarchy() at 80ms intervals
- on Maestro this stacked enough RPCs that hierarchy fetches began
timing out under load and the run stalled. Pass the full budget to
waitForAppToSettle and cap the follow-up structural poll at 600ms
with a 120ms interval, so the device sees at most a handful of
extra hierarchy reads per step.
This commit is contained in:
pj committed 2026-05-30 22:09:26 +05:30
1 parent d0cf5a98c7
commit a10791e00f
1 file changed
+17 -17
@@ -16,7 +16,8 @@ interface DriverBackend {
fun metrics(bundleId: String): MetricsSample
}
internal const val STABILITY_POLL_INTERVAL_MILLIS = 80L
internal const val STABILITY_POLL_INTERVAL_MILLIS = 120L
internal const val STABILITY_POLL_CAP_MILLIS = 600L
// pollUntilStable returns as soon as two consecutive snapshot() results are
// structurally identical, capped at timeoutMillis. snapshot must omit
@@ -424,17 +425,17 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
override fun waitForIdle(durationMillis: Long) {
// Two-stage settle: an mAnimating poll catches View-system animations,
// then a structural-hash poll catches Compose cross-fades where two
// composables are simultaneously alive but mAnimating is already false.
// then a short structural-hash poll catches Compose cross-fades where
// two composables are simultaneously alive but mAnimating is already
// false. The structural poll is hard-capped so we don't hammer the
// device with repeat hierarchy fetches when the UI never stabilizes.
if (durationMillis <= 0) return
val animationBudget = durationMillis / 2
val animationDeadline = System.currentTimeMillis() + animationBudget
val animationDeadline = System.currentTimeMillis() + durationMillis
while (System.currentTimeMillis() < animationDeadline) {
if (isDeviceIdle()) break
Thread.sleep(IDLE_POLL_INTERVAL_MILLIS)
}
val structuralBudget = durationMillis - animationBudget
pollUntilStable(structuralBudget) { structuralHash(hierarchy()) }
pollUntilStable(STABILITY_POLL_CAP_MILLIS) { structuralHash(hierarchy()) }
}
private fun isDeviceIdle(): Boolean {
@@ -504,12 +505,13 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
override fun waitForIdle(durationMillis: Long) {
// waitForAppToSettle returns early on Compose cross-fade transitions
// where both source and destination composables are semantically
// alive; a follow-up structural-hash poll lands on a single stable
// frame before the runner reads hierarchy + screenshot concurrently.
val maestroBudget = durationMillis / 2
val stabilityBudget = durationMillis - maestroBudget
driver.waitForAppToSettle(null, null, maestroBudget.toInt())
pollUntilStable(stabilityBudget) {
// alive; a follow-up short structural-hash poll lands on a single
// stable frame before the runner reads hierarchy + screenshot
// concurrently. The structural poll is hard-capped independently of
// durationMillis so we don't pile on hierarchy fetches when settle
// never converges.
driver.waitForAppToSettle(null, null, durationMillis.toInt())
pollUntilStable(STABILITY_POLL_CAP_MILLIS) {
structuralHash(
com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
.writeValueAsString(driver.contentDescriptor(false)),
@@ -701,10 +703,8 @@ class IosDriverBackend(private val udid: String) : DriverBackend {
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> = emptyList()
override fun waitForIdle(durationMillis: Long) = withReconnect {
val maestroBudget = durationMillis / 2
val stabilityBudget = durationMillis - maestroBudget
driver.waitForAppToSettle(null, null, maestroBudget.toInt())
pollUntilStable(stabilityBudget) {
driver.waitForAppToSettle(null, null, durationMillis.toInt())
pollUntilStable(STABILITY_POLL_CAP_MILLIS) {
structuralHash(
com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
.writeValueAsString(driver.contentDescriptor(false)),