From b1bf39bac5f5aa22f6f68ca50f9903f44a646d09 Mon Sep 17 00:00:00 2001 From: PJ Date: Fri, 14 Aug 2026 22:41:58 +0530 Subject: [PATCH] test(sidecar): pin that a slow read counts toward the stability streak --- .../dev/sanderling/sidecar/DriverBackend.kt | 23 +++++++---- .../sanderling/sidecar/StabilityPollTest.kt | 40 +++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index 634d0bf..b0ffebe 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -88,8 +88,14 @@ internal const val TRANSITION_POLL_INTERVAL_MILLIS = 100L // NavHost cross-fade is a 700ms tween (Compose navigation's default enter and // exit), it starts when the action lands rather than when the snapshot begins, // and the streak above has to fit after it. Measured on the emulator, a 600ms -// cap left about a third of fades unfinished. A layout that holds two routes at -// rest costs the full cap once per step and no more. +// cap left about a third of fades unfinished. +// +// A layout that holds two routes at rest pays the full cap on every snapshot +// RPC it is read with, and the runner issues more than one: fetchSyncedState +// (internal/runner) re-fetches a tree it considers transitional up to 4 times. +// It stops early once two consecutive fetches come back byte-identical, so such +// a layout costs 2 caps on a still tree and up to 4 on one that jitters +// underneath. Per step, not once per step. internal const val TRANSITION_POLL_CAP_MILLIS = 1500L // awaitSettledTree reads the hierarchy and, while the tree it gets back holds @@ -123,11 +129,14 @@ internal fun awaitSettledTree(read: () -> String): String { // state stable. // // The streak is measured from the start of the read that opened the current -// run of identical snapshots, not from when that read returned: a hierarchy -// fetch is not instantaneous, and the UI changing mid-fetch would have changed -// the snapshot, so the fetch's own duration is evidence of stability. On -// Android a fetch costs more than the poll interval, so charging it to the -// streak is the difference between two reads and four. +// run of identical snapshots, not from when that read returned, so a slow read +// is charged to the streak. That is a deliberate trade and not a free one: the +// quiet the poll actually OBSERVED spans the last read's start back to the +// first read's return, which is shorter than streakMillis by up to the two +// reads' durations. On Android a hierarchy fetch costs more than the poll +// interval, so charging it is the difference between two reads and four, and a +// caller wanting the full streak observed has to widen streakMillis rather than +// assume it. StabilityPollTest.slowSnapshotReadsCountTowardTheStreak pins this. internal fun pollUntilStable( timeoutMillis: Long, streakMillis: Long = MIN_STABLE_STREAK_MILLIS, diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/StabilityPollTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/StabilityPollTest.kt index f4e31af..ab599d0 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/StabilityPollTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/StabilityPollTest.kt @@ -16,6 +16,46 @@ class StabilityPollTest { assertTrue(elapsed < 3000L, "should not run to cap when stable, elapsed=${elapsed}ms") } + @Test fun slowSnapshotReadsCountTowardTheStreak() { + // The streak runs from the START of the read that opened the run of + // identical snapshots, so the read's own duration is charged to it. + // Every other test here uses an instant lambda and so passes under + // either semantics; this one is the difference. StubDriverBackend's + // waitForIdle polls a real `uiautomator dump`, which costs hundreds of + // milliseconds, so the slow read is the case it runs in. + val readMillis = 400L + val sampleStarts = mutableListOf() + val sampleEnds = mutableListOf() + val start = System.currentTimeMillis() + pollUntilStable(5000L) { + sampleStarts += System.currentTimeMillis() - start + Thread.sleep(readMillis) + sampleEnds += System.currentTimeMillis() - start + "stable" + } + val elapsed = System.currentTimeMillis() - start + + // One read plus one interval plus one read already clears 750ms, so the + // poll settles for two samples where an instant read takes four. + assertEquals( + 2, + sampleStarts.size, + "a ${readMillis}ms read should clear the streak in two samples, starts=$sampleStarts", + ) + assertTrue(elapsed >= MIN_STABLE_STREAK_MILLIS, "elapsed=${elapsed}ms") + + // What the poll actually watched: the last read began this long after + // the first one returned. It is the poll interval, not the streak, and + // a caller that needs MIN_STABLE_STREAK_MILLIS of observed quiet has to + // ask for a wider streak rather than assume this one delivers it. + val observedQuiet = sampleStarts.last() - sampleEnds.first() + assertTrue( + observedQuiet < MIN_STABLE_STREAK_MILLIS, + "the poll returned having observed ${observedQuiet}ms of quiet, not " + + "${MIN_STABLE_STREAK_MILLIS}ms; if that changed, the streak semantics changed", + ) + } + @Test fun streakResetsOnAnyChange() { // A late transition that fires after the prior streak has already // begun must reset the clock: the post-transition stable window has