waitForIdle() waits for the compose hierarchy to settle. Right after a rotation the window manager
has accepted but not yet delivered as a configuration change, the old Activity's composition is
already idle — so it returns, composeRule.activity still resolves to the old instance, and the
guard below reads an unchanged identity hash. Nothing waited for MainActivity to be rebuilt.
Two exits from one race
That is not a theory about the wedge alone; the clean version was measured. PR #217's API 33
gating leg, run 33698846104:
expected: 60 received: 60 failed: 1 completed cleanly: yes
SafPickerRoundTripTest > thePickedInputSurvivesARealRotation FAILED
java.lang.AssertionError: the rotation did not recreate MainActivity,
so the retained ViewModelStore was never used. Actual: 94319349
It failed on the second guard, which means the first passed — displayRotation was already
off natural. The display had rotated; the Activity had not been rebuilt yet. Land on the early side
of that gap and you get this assertion; land while the composition is being torn down and waitForIdle has nothing coherent to settle on, which is the 20-minute wedge this ticket opened
with.
The fix does not depend on that second half being right. A bounded wait turns a WEDGE_TIMEOUT
that costs the whole leg and names no test into a fast failure that says which test and what it was
waiting for. That is worth having even if the wedges turn out to have another cause.
The wait, and why not the obvious one
awaitRecreation() waits on a counter fed by the runner's lifecycle monitor:
Deliberately not polling composeRule.activity. That resolves through scenario.onActivity,
which blocks on the main thread — polling it across a recreation is a plausible reading of the very
wedge being fixed, so the obvious barrier could have been the bug. Reading an AtomicInteger touches
no looper.
Bound is 15 s: generous against the slow API 33/34 rotations this was measured on, and two orders of
magnitude inside the 1200 s WEDGE_TIMEOUT it replaces.
Both guards stay
They are what caught this, and the identity-hash one is still the assertion of the actual property.
It is now a backstop rather than the primary detector — with the barrier in front of it, a configChanges attribute or an orientation lock trips awaitRecreation's timeout first, with a
message that says what was being waited for.
Verification, on the local API 33 emulator
Whole suite, tools/local-emulator/run-e2e.sh 33:
run shape
result
fix in place
expected 60, received 60, failed 0, completed cleanly: yes
green
watcher mutated so the counter never increments — i.e. "the rotation did not recreate the Activity"
expected 60, received 60, failed 1, completed cleanly: yes
red in 15 s
SafPickerRoundTripTest > thePickedInputSurvivesARealRotation FAILED
androidx.compose.ui.test.ComposeTimeoutException: Condition
(the rotation did not recreate MainActivity within 15000 ms) still not satisfied after 15000 ms
That mutation is the point of the change: the run completes and attributes the failure to a named
test and a named condition, where the same missing recreation previously took the leg out for 20
minutes via WEDGE_TIMEOUT and named nothing.
What this does not show, stated plainly. The pre-fix flake was not reproduced on this host — the
green arm passes with or without the barrier here, because this machine rotates fast enough that the
gap never opens. So the evidence for the diagnosis is #217's CI failure quoted above, and the
evidence for the fix is the timeout path measured here; neither is a before/after on the same
machine, and the PR does not claim one.
Closes #122.
## The barrier was never a barrier
`thePickedInputSurvivesARealRotation` synchronised a rotation with one line:
```kotlin
device.setOrientationLandscape()
rotated = true
composeRule.waitForIdle()
```
`waitForIdle()` waits for the compose hierarchy to settle. Right after a rotation the window manager
has accepted but not yet delivered as a configuration change, the **old** Activity's composition is
already idle — so it returns, `composeRule.activity` still resolves to the old instance, and the
guard below reads an unchanged identity hash. Nothing waited for `MainActivity` to be rebuilt.
## Two exits from one race
That is not a theory about the wedge alone; the clean version was measured. PR #217's **API 33**
gating leg, run `33698846104`:
```
expected: 60 received: 60 failed: 1 completed cleanly: yes
SafPickerRoundTripTest > thePickedInputSurvivesARealRotation FAILED
java.lang.AssertionError: the rotation did not recreate MainActivity,
so the retained ViewModelStore was never used. Actual: 94319349
```
It failed on the **second** guard, which means the **first passed** — `displayRotation` was already
off natural. The display had rotated; the Activity had not been rebuilt yet. Land on the early side
of that gap and you get this assertion; land while the composition is being torn down and
`waitForIdle` has nothing coherent to settle on, which is the 20-minute wedge this ticket opened
with.
**The fix does not depend on that second half being right.** A bounded wait turns a `WEDGE_TIMEOUT`
that costs the whole leg and names no test into a fast failure that says which test and what it was
waiting for. That is worth having even if the wedges turn out to have another cause.
## The wait, and why not the obvious one
`awaitRecreation()` waits on a counter fed by the runner's lifecycle monitor:
```kotlin
private val recreationWatcher = ActivityLifecycleCallback { activity, stage ->
if (activity is MainActivity && stage == Stage.CREATED) recreations.incrementAndGet()
}
```
**Deliberately not polling `composeRule.activity`.** That resolves through `scenario.onActivity`,
which blocks on the main thread — polling it across a recreation is a plausible reading of the very
wedge being fixed, so the obvious barrier could have been the bug. Reading an `AtomicInteger` touches
no looper.
Bound is 15 s: generous against the slow API 33/34 rotations this was measured on, and two orders of
magnitude inside the 1200 s `WEDGE_TIMEOUT` it replaces.
## Both guards stay
They are what caught this, and the identity-hash one is still the assertion of the actual property.
It is now a **backstop** rather than the primary detector — with the barrier in front of it, a
`configChanges` attribute or an orientation lock trips `awaitRecreation`'s timeout first, with a
message that says what was being waited for.
## Verification, on the local API 33 emulator
Whole suite, `tools/local-emulator/run-e2e.sh 33`:
| | run shape | result |
|---|---|---|
| fix in place | `expected 60, received 60, failed 0, completed cleanly: yes` | green |
| watcher mutated so the counter never increments — i.e. "the rotation did not recreate the Activity" | `expected 60, received 60, failed 1, completed cleanly: yes` | red in **15 s** |
```
SafPickerRoundTripTest > thePickedInputSurvivesARealRotation FAILED
androidx.compose.ui.test.ComposeTimeoutException: Condition
(the rotation did not recreate MainActivity within 15000 ms) still not satisfied after 15000 ms
```
That mutation is the point of the change: the run **completes and attributes the failure to a named
test and a named condition**, where the same missing recreation previously took the leg out for 20
minutes via `WEDGE_TIMEOUT` and named nothing.
**What this does not show, stated plainly.** The pre-fix flake was not reproduced on this host — the
green arm passes with or without the barrier here, because this machine rotates fast enough that the
gap never opens. So the evidence for the diagnosis is #217's CI failure quoted above, and the
evidence for the fix is the timeout path measured here; neither is a before/after on the same
machine, and the PR does not claim one.
Gate: `compileDebugAndroidTestKotlin` + `ktlintCheck` + `detekt` + `lintDebug` green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #122.
The barrier was never a barrier
thePickedInputSurvivesARealRotationsynchronised a rotation with one line:waitForIdle()waits for the compose hierarchy to settle. Right after a rotation the window managerhas accepted but not yet delivered as a configuration change, the old Activity's composition is
already idle — so it returns,
composeRule.activitystill resolves to the old instance, and theguard below reads an unchanged identity hash. Nothing waited for
MainActivityto be rebuilt.Two exits from one race
That is not a theory about the wedge alone; the clean version was measured. PR #217's API 33
gating leg, run
33698846104:It failed on the second guard, which means the first passed —
displayRotationwas alreadyoff natural. The display had rotated; the Activity had not been rebuilt yet. Land on the early side
of that gap and you get this assertion; land while the composition is being torn down and
waitForIdlehas nothing coherent to settle on, which is the 20-minute wedge this ticket openedwith.
The fix does not depend on that second half being right. A bounded wait turns a
WEDGE_TIMEOUTthat costs the whole leg and names no test into a fast failure that says which test and what it was
waiting for. That is worth having even if the wedges turn out to have another cause.
The wait, and why not the obvious one
awaitRecreation()waits on a counter fed by the runner's lifecycle monitor:Deliberately not polling
composeRule.activity. That resolves throughscenario.onActivity,which blocks on the main thread — polling it across a recreation is a plausible reading of the very
wedge being fixed, so the obvious barrier could have been the bug. Reading an
AtomicIntegertouchesno looper.
Bound is 15 s: generous against the slow API 33/34 rotations this was measured on, and two orders of
magnitude inside the 1200 s
WEDGE_TIMEOUTit replaces.Both guards stay
They are what caught this, and the identity-hash one is still the assertion of the actual property.
It is now a backstop rather than the primary detector — with the barrier in front of it, a
configChangesattribute or an orientation lock tripsawaitRecreation's timeout first, with amessage that says what was being waited for.
Verification, on the local API 33 emulator
Whole suite,
tools/local-emulator/run-e2e.sh 33:expected 60, received 60, failed 0, completed cleanly: yesexpected 60, received 60, failed 1, completed cleanly: yesThat mutation is the point of the change: the run completes and attributes the failure to a named
test and a named condition, where the same missing recreation previously took the leg out for 20
minutes via
WEDGE_TIMEOUTand named nothing.What this does not show, stated plainly. The pre-fix flake was not reproduced on this host — the
green arm passes with or without the barrier here, because this machine rotates fast enough that the
gap never opens. So the evidence for the diagnosis is #217's CI failure quoted above, and the
evidence for the fix is the timeout path measured here; neither is a before/after on the same
machine, and the PR does not claim one.
Gate:
compileDebugAndroidTestKotlin+ktlintCheck+detekt+lintDebuggreen.🤖 Generated with Claude Code