test(sidecar): pin that a slow read counts toward the stability streak

This commit is contained in:
pj committed 2026-08-14 22:41:58 +05:30
1 parent 9f1dfe27ba
commit b1bf39bac5
2 files changed
+56 -7

No files matched your search

@@ -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,
@@ -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<Long>()
val sampleEnds = mutableListOf<Long>()
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