The rotation test hangs on API 33 and 34, wedging the leg for 20 minutes #122

Closed
opened 2026-08-26 03:26:45 +00:00 by JMR-dev · 7 comments
JMR-dev commented 2026-08-26 03:26:45 +00:00 (Migrated from github.com)

SafPickerRoundTripTest.thePickedInputSurvivesARealRotation can hang forever on
API 33 and 34. The leg then dies to WEDGE_TIMEOUT after 1200 s and the PR goes
red for something it did not cause.

Two occurrences, both on PRs that cannot plausibly be responsible, and both
end at exactly the same place in the TestRunner log:

run leg last event
#115 (docs-only) 32921547861 API 34 02:13:50.515 started: thePickedInputSurvivesARealRotation
#117 (validation suggestions) 32924766720 API 33 03:07:07.917 started: thePickedInputSurvivesARealRotation

grep -c "finished: thePickedInputSurvivesARealRotation" is 0 in both wedge
diagnostics artifacts. It is the last test started in both, and nothing follows.

The framework is not down. In both artifacts every binder service the wedge
probe checks is present:

service check input:        Service input: found
service check window:       Service window: found
service check activity:     Service activity: found
service check media.player: Service media.player: found

So this is not #108 and not the surfaceflinger/gralloc family — nothing has
crashed. The test simply never returns. The two differ in whether the
instrumentation process survives: API 34 shows pids -- app: 6121 test: 6616,
API 33 shows test: <none>, and on API 33 the SIGQUIT dump could not be taken
(kill: 6473: Operation not permitted), so there is no thread dump for the
hang yet.

Why this test specifically. It is the only test in the suite that rotates the
display, and it already carries @FailsOnEmulatorApi37 because a rotation
"rebuilds every surface on screen at once, which the mapper does not survive" on
the 37 images. That marker covers a crash on 37. What is happening on 33 and 34
is a hang, which no marker covers and which costs 20 minutes a time.

Do not fix this by marking it @FailsOnEmulatorApi37 for more API levels. That
annotation means "this image cannot run this test", and the whole point of the
rotation test is that API 33-36 answer the question API 37 cannot. Silencing it
across the matrix would leave nothing asserting that a picked file survives a real
rotation.

Suggested first step: get a thread dump. The wedge probe already attempts
SIGQUIT and was refused on API 33; making that work (or using am dumpheap /
debuggerd against the instrumentation pid) would say where it is blocked —
almost certainly a UiAutomator wait or an idling-resource wait that has no
timeout. A per-test timeout would convert a 20-minute wedge into a normal failure
with a stack, which is strictly better even before the root cause is known.

Related but distinct:

  • #118 / #121 — the wedge reporting, which says completed cleanly: yes.
  • #102 — this was that ticket's fourth "mode"; it now has a name.
`SafPickerRoundTripTest.thePickedInputSurvivesARealRotation` can hang forever on API 33 and 34. The leg then dies to `WEDGE_TIMEOUT` after 1200 s and the PR goes red for something it did not cause. **Two occurrences, both on PRs that cannot plausibly be responsible**, and both end at exactly the same place in the TestRunner log: | | run | leg | last event | |---|---|---|---| | #115 (docs-only) | `32921547861` | API 34 | `02:13:50.515 started: thePickedInputSurvivesARealRotation` | | #117 (validation suggestions) | `32924766720` | API 33 | `03:07:07.917 started: thePickedInputSurvivesARealRotation` | `grep -c "finished: thePickedInputSurvivesARealRotation"` is **0** in both wedge diagnostics artifacts. It is the last test started in both, and nothing follows. **The framework is not down.** In both artifacts every binder service the wedge probe checks is present: ``` service check input: Service input: found service check window: Service window: found service check activity: Service activity: found service check media.player: Service media.player: found ``` So this is not #108 and not the surfaceflinger/gralloc family — nothing has crashed. The test simply never returns. The two differ in whether the instrumentation process survives: API 34 shows `pids -- app: 6121 test: 6616`, API 33 shows `test: <none>`, and on API 33 the SIGQUIT dump could not be taken (`kill: 6473: Operation not permitted`), so there is no thread dump for the hang yet. **Why this test specifically.** It is the only test in the suite that rotates the display, and it already carries `@FailsOnEmulatorApi37` because a rotation "rebuilds every surface on screen at once, which the mapper does not survive" on the 37 images. That marker covers a *crash* on 37. What is happening on 33 and 34 is a *hang*, which no marker covers and which costs 20 minutes a time. **Do not fix this by marking it @FailsOnEmulatorApi37 for more API levels.** That annotation means "this image cannot run this test", and the whole point of the rotation test is that API 33-36 answer the question API 37 cannot. Silencing it across the matrix would leave nothing asserting that a picked file survives a real rotation. **Suggested first step:** get a thread dump. The wedge probe already attempts SIGQUIT and was refused on API 33; making that work (or using `am dumpheap` / `debuggerd` against the instrumentation pid) would say *where* it is blocked — almost certainly a `UiAutomator` wait or an idling-resource wait that has no timeout. A per-test timeout would convert a 20-minute wedge into a normal failure with a stack, which is strictly better even before the root cause is known. **Related but distinct:** - #118 / #121 — the wedge *reporting*, which says `completed cleanly: yes`. - #102 — this was that ticket's fourth "mode"; it now has a name.
JMR-dev commented 2026-08-26 03:35:33 +00:00 (Migrated from github.com)

Two measurements that constrain the fix

1. Normal duration: 9.572 s. From the JUnit XML of a passing API 33 leg (run 32925531744):

  thePickedInputSurvivesARealRotation                    9.572s   <- slowest test in the suite
  pickingAFileThroughTheSystemPickerFillsInTheFileCard   3.629s
  ReattachOnLaunchTest.ignoresAFinishedConversion...     5.078s

So the wedge runs 1200 s against a 9.6 s test — roughly 125x. A per-test timeout anywhere in the
30-90 s range would keep enormous headroom over the normal case while turning a 20-minute anonymous
wedge into a fast failure with a stack. That is worth doing before the root cause is known: it
costs nothing when the test is healthy and it is the thing that would let the next occurrence
diagnose itself.

Pick the number against the slowest observed pass, not against 9.572 — this is the slowest test in
the suite and runner load varies. But there is a very wide safe band here.

2. Local reproduction is blocked right now, and not by the emulator. A VirtualBox VM is running
on this host (VirtualBoxVM --comment Gentoo --startvm 83280c91-…, pid 709807). VirtualBox takes
VT-x exclusively on Intel, so every AVD dies with KVM: entry failed, hardware error 0x0 even though
/dev/kvm exists and is writable and kvm_intel is loaded. An agent hit this earlier today and
independently confirmed it on a second AVD.

That matters for whoever picks this up: tools/local-emulator/run-e2e.sh covers API 33-36 and would
normally be the fastest way to reproduce a 33/34 hang, but not while that VM is up. It is the
maintainer's VM and should not be shut down to make room for a test run — either wait for it, or
iterate on CI, where the mode has appeared twice in roughly 500 gating leg-attempts. That is a slow
loop, which is the other reason the timeout-and-stack change is worth landing first.

## Two measurements that constrain the fix **1. Normal duration: 9.572 s.** From the JUnit XML of a passing API 33 leg (run `32925531744`): ``` thePickedInputSurvivesARealRotation 9.572s <- slowest test in the suite pickingAFileThroughTheSystemPickerFillsInTheFileCard 3.629s ReattachOnLaunchTest.ignoresAFinishedConversion... 5.078s ``` So the wedge runs **1200 s against a 9.6 s test — roughly 125x**. A per-test timeout anywhere in the 30-90 s range would keep enormous headroom over the normal case while turning a 20-minute anonymous wedge into a fast failure with a stack. That is worth doing *before* the root cause is known: it costs nothing when the test is healthy and it is the thing that would let the next occurrence diagnose itself. Pick the number against the slowest observed *pass*, not against 9.572 — this is the slowest test in the suite and runner load varies. But there is a very wide safe band here. **2. Local reproduction is blocked right now, and not by the emulator.** A VirtualBox VM is running on this host (`VirtualBoxVM --comment Gentoo --startvm 83280c91-…`, pid 709807). VirtualBox takes VT-x exclusively on Intel, so every AVD dies with `KVM: entry failed, hardware error 0x0` even though `/dev/kvm` exists and is writable and `kvm_intel` is loaded. An agent hit this earlier today and independently confirmed it on a second AVD. That matters for whoever picks this up: `tools/local-emulator/run-e2e.sh` covers API 33-36 and would normally be the fastest way to reproduce a 33/34 hang, **but not while that VM is up**. It is the maintainer's VM and should not be shut down to make room for a test run — either wait for it, or iterate on CI, where the mode has appeared twice in roughly 500 gating leg-attempts. That is a slow loop, which is the other reason the timeout-and-stack change is worth landing first.
JMR-dev commented 2026-08-27 02:48:08 +00:00 (Migrated from github.com)

Still fires after #127, on a diff that cannot have caused it.

Run 33033036857, PR #131 — a docs-only change adding one markdown file, no source, no build, no workflow:

  expected:          60
  received:          60
  failed:            unknown
  wedged:            yes -- gradle was killed after 1200s and never returned
  completed cleanly: no

23m08s wall clock. Diagnostics are in the e2e-wedge-api33 artifact (49 KB).

Frequency, from the last eight status_check runs (cancelled runs excluded):

leg result
6 completed API 33 legs 5 pass at ~7 min, 1 wedge at 23 min
API 34 no wedge observed in this window

So it is intermittent, and on this sample it is API 33 only — this ticket's title names 34 as well, which nothing in this window reproduces.

The part worth keeping separate. received: 60 means all sixty tests reported before gradle hung, so the wedge is not costing test execution — it is costing the verdict, since failed: reads unknown. That distinction is only visible because of the wedged: row #118 added; without it this table would have read received: 60, completed cleanly: no with no way to tell a hang from an abort.

That has a consequence beyond this ticket, which is how I got here: #88 closed on the reasoning that the CI matrix exercises all three ConversionForegroundType API regimes. That reasoning is intact — the tests do run — but the API 33 leg cannot be relied on to report a failure while this is live. Recorded in docs/coverage-read-findings.md (PR #131), which also notes that a @Config(sdk = 33) / @Config(sdk = 34) JVM test would pin those arms deterministically for about three lines, independent of emulator health.

**Still fires after #127, on a diff that cannot have caused it.** Run [`33033036857`](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/33033036857), PR #131 — a docs-only change adding one markdown file, no source, no build, no workflow: ``` expected: 60 received: 60 failed: unknown wedged: yes -- gradle was killed after 1200s and never returned completed cleanly: no ``` 23m08s wall clock. Diagnostics are in the `e2e-wedge-api33` artifact (49 KB). **Frequency, from the last eight `status_check` runs** (cancelled runs excluded): | leg | result | |---|---| | 6 completed API 33 legs | 5 pass at ~7 min, 1 wedge at 23 min | | API 34 | no wedge observed in this window | So it is intermittent, and on this sample it is API 33 only — this ticket's title names 34 as well, which nothing in this window reproduces. **The part worth keeping separate.** `received: 60` means all sixty tests reported before gradle hung, so the wedge is *not* costing test execution — it is costing the verdict, since `failed:` reads `unknown`. That distinction is only visible because of the `wedged:` row #118 added; without it this table would have read `received: 60, completed cleanly: no` with no way to tell a hang from an abort. That has a consequence beyond this ticket, which is how I got here: **#88 closed on the reasoning that the CI matrix exercises all three `ConversionForegroundType` API regimes.** That reasoning is intact — the tests do run — but the API 33 leg cannot be relied on to *report* a failure while this is live. Recorded in `docs/coverage-read-findings.md` (PR #131), which also notes that a `@Config(sdk = 33)` / `@Config(sdk = 34)` JVM test would pin those arms deterministically for about three lines, independent of emulator health.
JMR-dev commented 2026-08-27 02:56:12 +00:00 (Migrated from github.com)

Retried the same leg on the same commit: it passed. That settles "intermittent" properly — the earlier comment inferred it from a cross-run sample, which cannot rule out something about the diff. This controls for the diff entirely.

Commit 2599286, job E2E API 33, run 33033036857:

attempt duration shape
1 23m08s received: 60, failed: unknown, wedged: yes — gradle killed after 1200s
2 (rerun, no other change) 6m47s expected: 60, received: 60, failed: 0, completed cleanly: yes

Same SHA, same runner image, same workflow. So the wedge is a race, not a property of any particular diff — and received: 60 on the wedged attempt says it lands after the last test reports, while gradle is shutting down.


Unrelated trap found while doing this, worth recording here since this is the ticket people will land on when a leg wedges. status_check.yml sets concurrency: group: status-check-${{ github.ref }} with cancel-in-progress: true. Re-running a job on an older run for the same ref re-enters that group and cancels the newer in-flight run — in this case a full run that had just started for the branch head. gh pr checks then reports every cancelled job as fail, which reads exactly like a build break: nine jobs "failing" in 42 seconds on a docs-only diff.

So: retry a wedged leg only when nothing newer is queued for that ref, or re-run the newest run rather than the one that wedged.

**Retried the same leg on the same commit: it passed.** That settles "intermittent" properly — the earlier comment inferred it from a cross-run sample, which cannot rule out something about the diff. This controls for the diff entirely. Commit `2599286`, job `E2E API 33`, run [`33033036857`](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/33033036857): | attempt | duration | shape | |---|---|---| | 1 | 23m08s | `received: 60, failed: unknown, wedged: yes — gradle killed after 1200s` | | 2 (rerun, no other change) | 6m47s | `expected: 60, received: 60, failed: 0, completed cleanly: yes` | Same SHA, same runner image, same workflow. So the wedge is a race, not a property of any particular diff — and `received: 60` on the wedged attempt says it lands *after* the last test reports, while gradle is shutting down. --- **Unrelated trap found while doing this, worth recording here since this is the ticket people will land on when a leg wedges.** `status_check.yml` sets `concurrency: group: status-check-${{ github.ref }}` with `cancel-in-progress: true`. Re-running a job on an **older** run for the same ref re-enters that group and **cancels the newer in-flight run** — in this case a full run that had just started for the branch head. `gh pr checks` then reports every cancelled job as `fail`, which reads exactly like a build break: nine jobs "failing" in 42 seconds on a docs-only diff. So: retry a wedged leg only when nothing newer is queued for that ref, or re-run the newest run rather than the one that wedged.
JMR-dev commented 2026-08-27 03:45:28 +00:00 (Migrated from github.com)

API 34 wedges too. Correcting my earlier comment, which said "on this sample it is API 33 only" — that was a five-run window and it does not hold.

Run 33036060139, PR #144, E2E API 34:

  expected:          60
  received:          60
  failed:            unknown
  wedged:            yes -- gradle was killed after 1200s and never returned
  completed cleanly: no

Identical shape to the API 33 wedge two hours earlier: all sixty tests report, then gradle does not return. So this ticket's title was right to name both levels, and the API-33-only reading was an artefact of sample size, not a narrowing of the bug.

Running tally across the PRs opened today (#131, #144–#149), counting only legs that completed or wedged:

leg wedges seen
API 33 1
API 34 1
API 35 / 36 / 37 0 so far

Two wedges across roughly forty leg-runs, on two different API levels, on diffs that are docs-only or JVM-test-only. Both retried clean.

That last point is the useful one for anyone triaging: a wedged leg has always passed on a plain re-run so far, and received: N on the wedged run says the tests themselves completed. What the wedge costs is the verdict (failed: unknown), not the coverage.

**API 34 wedges too.** Correcting my earlier comment, which said "on this sample it is API 33 only" — that was a five-run window and it does not hold. Run [`33036060139`](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/33036060139), PR #144, `E2E API 34`: ``` expected: 60 received: 60 failed: unknown wedged: yes -- gradle was killed after 1200s and never returned completed cleanly: no ``` Identical shape to the API 33 wedge two hours earlier: all sixty tests report, then gradle does not return. So this ticket's title was right to name both levels, and the API-33-only reading was an artefact of sample size, not a narrowing of the bug. **Running tally across the PRs opened today** (#131, #144–#149), counting only legs that completed or wedged: | leg | wedges seen | |---|---| | API 33 | 1 | | API 34 | 1 | | API 35 / 36 / 37 | 0 so far | Two wedges across roughly forty leg-runs, on two different API levels, on diffs that are docs-only or JVM-test-only. Both retried clean. That last point is the useful one for anyone triaging: **a wedged leg has always passed on a plain re-run so far**, and `received: N` on the wedged run says the tests themselves completed. What the wedge costs is the verdict (`failed: unknown`), not the coverage.
JMR-dev commented 2026-08-27 13:27:40 +00:00 (Migrated from github.com)

The wedge guard works. What happens after it does not — and that is what turns a wedge into a cancelled job rather than a failed one.

Timeline from #151's E2E API 37, run 33071570911, job 98515285726:

time event
12:31:29 > Task :app:connectedDebugAndroidTest
12:48:44 run-shape report: wedged: yes -- gradle was killed after 1200s and never returned
12:49:20 ::warning::E2E api37 WEDGED (api37)
12:49:20 → 13:25:05 nothing. 36 minutes of silence.
13:25:05 ##[error]The operation was canceled. — the job's timeout-minutes: 60 cap
13:25:09 Terminate orphan process: qemu-system-x86_64-headless, adb, two java

So WEDGE_TIMEOUT=1200 fired exactly as designed, the report printed, the warning printed — and then the step sat for another 36 minutes.

Where it stops

.github/scripts/e2e-run.sh, last five lines:

if [ -n "$wedged" ]; then
  capture_wedge "api${LABEL}"
else
  echo "::error::E2E api${LABEL} failed (exit $status)"
fi
dump_diagnostics                       # <-- 36 minutes here
kill "$LOGCAT_PID" 2>/dev/null || true # <-- never reached
exit "$status"                         # <-- never reached

dump_diagnostics probes the device — adb logcat -d -b crash, adb shell, and friends. Every one of them is || true-guarded, and the file header states the rule they exist for:

Every probe is guarded with || true. A diagnostic must never be the thing that turns a run red.

|| true guards against a non-zero exit. It does nothing about a probe that never exits at all — and these probes are aimed at the emulator that has just finished proving it stopped answering. That is the gap: the discipline is enforced for exit codes and not for time.

Why it costs more than the 36 minutes

  1. The job ends cancelled, not failed with the wedge's own status. gh pr checks renders that as a failure with no cause, and the careful wedged: row is buried above half an hour of silence — so the one mechanism built to explain a wedge is the thing least likely to be read.
  2. kill "$LOGCAT_PID" never runs, which is why the runner reports orphan adb and qemu at cleanup.
  3. It blocks merges. This one stopped a seven-PR stack, since a cancelled required check is not green.

Cheap fix

Bound the probes the same way the gradle client already is — timeout around dump_diagnostics, or per probe. The value of the diagnostics is entirely in the first few seconds of output; nothing here is worth 36 minutes. That would also let lines 39-40 run, so a wedge would exit with its own status and reap its logcat.

Not fixed here — filing rather than changing CI in the middle of merging a stack. Happy to do it as its own PR.

**The wedge guard works. What happens *after* it does not** — and that is what turns a wedge into a cancelled job rather than a failed one. Timeline from #151's `E2E API 37`, run `33071570911`, job `98515285726`: | time | event | |---|---| | 12:31:29 | `> Task :app:connectedDebugAndroidTest` | | 12:48:44 | run-shape report: `wedged: yes -- gradle was killed after 1200s and never returned` | | 12:49:20 | `::warning::E2E api37 WEDGED (api37)` | | **12:49:20 → 13:25:05** | **nothing. 36 minutes of silence.** | | 13:25:05 | `##[error]The operation was canceled.` — the job's `timeout-minutes: 60` cap | | 13:25:09 | `Terminate orphan process: qemu-system-x86_64-headless`, `adb`, two `java` | So `WEDGE_TIMEOUT=1200` fired exactly as designed, the report printed, the warning printed — and then the step sat for another 36 minutes. ## Where it stops `.github/scripts/e2e-run.sh`, last five lines: ```bash if [ -n "$wedged" ]; then capture_wedge "api${LABEL}" else echo "::error::E2E api${LABEL} failed (exit $status)" fi dump_diagnostics # <-- 36 minutes here kill "$LOGCAT_PID" 2>/dev/null || true # <-- never reached exit "$status" # <-- never reached ``` `dump_diagnostics` probes the device — `adb logcat -d -b crash`, `adb shell`, and friends. Every one of them is `|| true`-guarded, and the file header states the rule they exist for: > Every probe is guarded with `|| true`. A diagnostic must never be the thing that turns a run red. **`|| true` guards against a non-zero exit. It does nothing about a probe that never exits at all** — and these probes are aimed at the emulator that has just finished proving it stopped answering. That is the gap: the discipline is enforced for exit codes and not for time. ## Why it costs more than the 36 minutes 1. The job ends **`cancelled`**, not failed with the wedge's own status. `gh pr checks` renders that as a failure with no cause, and the careful `wedged:` row is buried above half an hour of silence — so the one mechanism built to explain a wedge is the thing least likely to be read. 2. `kill "$LOGCAT_PID"` never runs, which is why the runner reports orphan `adb` and `qemu` at cleanup. 3. It blocks merges. This one stopped a seven-PR stack, since a cancelled required check is not green. ## Cheap fix Bound the probes the same way the gradle client already is — `timeout` around `dump_diagnostics`, or per probe. The value of the diagnostics is entirely in the first few seconds of output; nothing here is worth 36 minutes. That would also let lines 39-40 run, so a wedge would exit with its own status and reap its logcat. Not fixed here — filing rather than changing CI in the middle of merging a stack. Happy to do it as its own PR.
JMR-dev commented 2026-09-06 00:02:48 +00:00 (Migrated from github.com)

A third occurrence, and it is a different symptom of the same test — which points at a cause

PR #217, run 33698846104, API 33 gating leg. This one did not wedge:

  expected:          60
  received:          60
  failed:            1
  completed cleanly: yes
SafPickerRoundTripTest > thePickedInputSurvivesARealRotation[test(AVD) - 13] FAILED
  java.lang.AssertionError: the rotation did not recreate MainActivity,
  so the retained ViewModelStore was never used. Actual: 94319349

So the test ran to completion and asserted, rather than hanging. Same test, same API level as row 2
of the table above, opposite run shape.

What that rules in

The failure is on the second guard, which means the first one passed: device.displayRotation
was already not NATURAL_ROTATION. The display had rotated. What had not happened is the Activity
being rebuilt — System.identityHashCode(composeRule.activity) was unchanged.

That combination is not "the emulator ignored the rotation". It is the test returning from its
barrier before the recreation it is waiting for has happened
, because the barrier does not wait for
that:

device.setOrientationLandscape()
rotated = true
composeRule.waitForIdle()          // <- the only synchronisation

waitForIdle() waits for the compose hierarchy to go idle. Immediately after a rotation request that
the window manager has accepted but not yet delivered as a configuration change, the old Activity's
composition is idle — so waitForIdle() returns, composeRule.activity still resolves to the old
instance, and the identity hash matches. Nothing in the test waits for MainActivity to be destroyed
and rebuilt.

This is a test bug, not only an image bug, and it is the more likely reading of the wedges too:
on API 33/34 the rotation is slow enough for the gap between "display rotated" and "Activity
recreated" to be observable. Land on the early side and you get this assertion; land while the
composition is being torn down and waitForIdle() has nothing coherent to settle on, and you get the
20-minute wedge in the table above. One race, two exits.

Candidate fix

Replace waitForIdle() with a barrier that waits for the thing being asserted — poll until
System.identityHashCode(composeRule.activity) differs from before, bounded, or register an
ActivityLifecycleCallbacks / ActivityMonitor for the recreation and wait on that. Then
waitForIdle() for the composition. The two guards stay as they are; they are what caught this.

Worth doing on this ticket rather than under the emulator-infra umbrella (#190): unlike the
hasReadColorBufferDma aborts, nothing here needs the image to change.

Filed while shepherding the wave-4 PRs (#206-#217). Ten of the twelve are green on every gating
leg; this is one of the two that are not.

## A third occurrence, and it is a *different symptom of the same test* — which points at a cause PR #217, run `33698846104`, **API 33 gating leg**. This one did **not** wedge: ``` expected: 60 received: 60 failed: 1 completed cleanly: yes ``` ``` SafPickerRoundTripTest > thePickedInputSurvivesARealRotation[test(AVD) - 13] FAILED java.lang.AssertionError: the rotation did not recreate MainActivity, so the retained ViewModelStore was never used. Actual: 94319349 ``` So the test ran to completion and asserted, rather than hanging. Same test, same API level as row 2 of the table above, opposite run shape. ### What that rules in The failure is on the **second** guard, which means the **first one passed**: `device.displayRotation` was already not `NATURAL_ROTATION`. The display had rotated. What had not happened is the Activity being rebuilt — `System.identityHashCode(composeRule.activity)` was unchanged. That combination is not "the emulator ignored the rotation". It is **the test returning from its barrier before the recreation it is waiting for has happened**, because the barrier does not wait for that: ```kotlin device.setOrientationLandscape() rotated = true composeRule.waitForIdle() // <- the only synchronisation ``` `waitForIdle()` waits for the compose hierarchy to go idle. Immediately after a rotation request that the window manager has accepted but not yet delivered as a configuration change, the *old* Activity's composition is idle — so `waitForIdle()` returns, `composeRule.activity` still resolves to the old instance, and the identity hash matches. Nothing in the test waits for `MainActivity` to be destroyed and rebuilt. **This is a test bug, not only an image bug**, and it is the more likely reading of the wedges too: on API 33/34 the rotation is slow enough for the gap between "display rotated" and "Activity recreated" to be observable. Land on the early side and you get this assertion; land while the composition is being torn down and `waitForIdle()` has nothing coherent to settle on, and you get the 20-minute wedge in the table above. One race, two exits. ### Candidate fix Replace `waitForIdle()` with a barrier that waits for the thing being asserted — poll until `System.identityHashCode(composeRule.activity)` differs from `before`, bounded, or register an `ActivityLifecycleCallbacks` / `ActivityMonitor` for the recreation and wait on that. Then `waitForIdle()` for the composition. The two guards stay as they are; they are what caught this. Worth doing on this ticket rather than under the emulator-infra umbrella (#190): unlike the `hasReadColorBufferDma` aborts, nothing here needs the image to change. *Filed while shepherding the wave-4 PRs (#206-#217). Ten of the twelve are green on every gating leg; this is one of the two that are not.*
JMR-dev commented 2026-09-06 01:01:40 +00:00 (Migrated from github.com)

The wedge recurred during the wave-4 merge train — on a PR that does not contain the fix

PR #213 (test/theme-follows-system-dark, whose diff is one Robolectric test file), run
34001741668, API 34:

  expected:          60
  received:          60
  failed:            unknown
  wedged:            yes -- gradle was killed after 1200s and never returned
  completed cleanly: no

#213 sits below #219 in the merge order, so it carries the old waitForIdle() barrier. This is the
fourth occurrence of the wedge on this ticket and the first one observed while knowing what to look
for
: same API level band (33/34), same 1200 s WEDGE_TIMEOUT, no attribution in the run shape
beyond the wedged: row that #118 added.

Worth stating plainly because it is the argument for #219 rather than a restatement of the problem:
the leg cost 20 minutes and still cannot say which test hung. With the bounded barrier the same
condition fails in 15 s naming itself — measured on the local API 33 emulator in #219, where
neutering the recreation counter produced

androidx.compose.ui.test.ComposeTimeoutException: Condition
(the rotation did not recreate MainActivity within 15000 ms) still not satisfied after 15000 ms

with the run still reporting received 60/60, completed cleanly: yes.

## The wedge recurred during the wave-4 merge train — on a PR that does not contain the fix PR #213 (`test/theme-follows-system-dark`, whose diff is one Robolectric test file), run `34001741668`, **API 34**: ``` expected: 60 received: 60 failed: unknown wedged: yes -- gradle was killed after 1200s and never returned completed cleanly: no ``` #213 sits below #219 in the merge order, so it carries the old `waitForIdle()` barrier. This is the fourth occurrence of the wedge on this ticket and the first one observed *while knowing what to look for*: same API level band (33/34), same 1200 s `WEDGE_TIMEOUT`, no attribution in the run shape beyond the `wedged:` row that #118 added. Worth stating plainly because it is the argument for #219 rather than a restatement of the problem: **the leg cost 20 minutes and still cannot say which test hung.** With the bounded barrier the same condition fails in 15 s naming itself — measured on the local API 33 emulator in #219, where neutering the recreation counter produced ``` androidx.compose.ui.test.ComposeTimeoutException: Condition (the rotation did not recreate MainActivity within 15000 ms) still not satisfied after 15000 ms ``` with the run still reporting `received 60/60, completed cleanly: yes`.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMediaConverter#122