Wait for the rotation to rebuild the Activity, not for the composition to idle #219

Merged
JMR-dev merged 13 commits from fix/rotation-waits-for-recreation into main 2026-09-06 01:43:48 +00:00
JMR-dev commented 2026-09-06 00:28:33 +00:00 (Migrated from github.com)

Closes #122.

The barrier was never a barrier

thePickedInputSurvivesARealRotation synchronised a rotation with one line:

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:

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

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)
Sign in to join this conversation.