Is there a line in your logs that prints every single run and nobody looks at?
I looked at mine for three weeks and read it as "good, recovery is working." It was the cause.
The symptom
I have a bot that drives a USB-tethered Android phone with adb. It cycles four accounts in a social app: switch account, do a short session, move on.
Account switching failed 8–19% of the time. Two shapes in the log:
top-bar header missing (candidates=[]) — retrying after reset ... 42
ensure_home failed on first pass -> app reset, retry ... 30Both say "I restarted the app and the screen still isn't what I expected." 76 failures out of 500 attempts.
I fixed it three times and the rate didn't move
Everything I found first was a real defect.
- The lock was at the wrong layer. A second job drives the same phone. Its lock check lived inside the Python, but the shell above it had already launched the app over
adb. The lock existed — it just ran after the phone had already been touched. - The restart wait was a clock. A fixed
sleep(6)afterforce-stop. Measured on the device: foreground at 2.5–3.2s, but the tab I needed only appeared at 7.9–9.4s. Returning at 6s meant the caller judged a splash screen. - The foreground check was one-shot. If another app surfaced for a moment, give up immediately. But that mismatch is almost always transient.
I fixed all three and confirmed each mechanism on the device. The failure rate didn't move.
That's where I should have stopped. Three fixes with no improvement doesn't mean the hypotheses were wrong — it means I was looking at the wrong layer.
I stopped reading failures one at a time
Instead of reading 76 failures individually, I printed one character per account, four per run. . is success, E is failure.
2026-08-16 11:00 ...E
2026-08-17 06:00 ...E
2026-08-22 17:00 ...E
2026-08-23 06:00 ...E
2026-08-24 17:00 ...E
2026-08-25 13:00 ...E
2026-09-01 22:00 ...E...E appeared 14 times — by far the most common shape. Only the last account fails. And the line before it was always the previous account finishing normally.
The failures weren't happening while the bot worked. They happened when it finished and moved to the next account.
That changes the question. Where is the screen when a session ends?
So I counted
A session ends on one of three screens. On each of them I counted the nodes the bot treats as "a modal's close button." The escape routine looks like this:
repeat 8 times:
target tab visible? -> success
close button found? -> tap it # dismiss modals first
otherwise -> back()The DISMISS list holds labels like Not now, Close, Skip, Cancel, Dismiss. Here's what's actually on those screens:
| Screen where a session ends | Nodes matching "close" |
|---|---|
| Follower list | 4 |
| Filtered list | 2 |
| Suggestions list | 8 (row_recommended_hide_icon_button) |
content-desc="Dismiss". Not a modal. It's the per-row "hide this suggestion" button.
Tap it and one suggestion disappears; the screen stays. So the next iteration finds another close button. Eight iterations burn that way. back() is never pressed once.
What would you change
The easy fix is to drop "Dismiss" from the label list. One line.
I didn't. Label lists die quietly on one rename and one extra language. If the app relabels that button to Hide, the same trap comes back; if a real modal elsewhere uses Dismiss, now I can't close it.
So I stopped looking at the label and looked at the count.
A modal has one close button. If there are several, it isn't a modal — it's a list.
cands = find_all(root, lambda t, d: t in DISMISS or d in DISMISS)
if len(cands) == 1:
tap(*cands[0]) # only a real modal
else:
back() # zero or many: back outCounts survive renames and translations. Labels don't.
Rewinding the domino
- A session always ends on one of those three screens. → The escape routine's first pass fails on every account, deterministically.
- So the bot takes a
force-stopcold launch on every single switch. - Right after a cold launch the screen is still loading. →
candidates=[](42 cases), or the loop pressesback()out of the app entirely andensure_homefails (30 cases).
All three earlier fixes were fixes to step 3. They narrowed the race, but the condition that created the race was untouched, because step 1 was forcing a cold launch on every transition.
And step 1's fingerprint was in the logs the whole time: ensure_home failed -> app reset, retry — 240 times across 220 switches. More than once per transition. I read it as "recovery is working."
When your recovery path fires constantly, it isn't recovery. It's a symptom.
Verification
I started from the exact screen a session ends on and ran real switches.
| Check | Result |
|---|---|
| Escape routine — each of the three screens | 3/3 succeeded, 0 restarts (7.9s · 12.4s · 12.5s) |
| Account switch — 4 accounts from those screens | 4/4 succeeded, 0 restarts (27–33s) |
Before the fix, all eight of those would have taken a restart. The trap nodes are always present, so it's deterministic.
Three things to check in your own code
- How often does your recovery path fire? Count it. If the exceptional path is the constant path, it isn't exceptional.
- Are you locating UI by label? Check whether that label is also attached to list rows. Counting how many exist on screen is the cheapest test there is.
- Are you reading failures one at a time? Print one line per run summarizing the outcome of each unit. The distribution points at the cause —
...Ealone cut my search from 500 events to three screens.
The honest part
No verdict yet. The verification above is a condition I built by hand; the unattended sample is zero. Deciding what counts as success afterwards lets any result read as a win, so I fixed the bar first.
- Baseline:
reset, retryat 1.09 per switch (240 / 220) - PASS: 2 or fewer across the next 3 runs (about 12 switches). If the baseline holds, the expected count is 13.
The switch failure rate itself fills its denominator slowly (16–24 switches/day), so that one gets judged two days out. If failures survive, the screen XML now gets written to a file at the moment of giving up.
Some of it is still unexplained. Four of the 500 (0.8%) have different shapes — two where the switcher sheet opened but the account row wasn't found, two where the tap itself failed. The sample is too small to narrow. I'm leaving them open rather than folding them into this cause.
One more thing fell out of the investigation. The session function returned a scalar from two mid-function returns, while the caller unpacks f, cold = session(...). Taking that path raises TypeError and kills the whole cycle, silently dropping every remaining account. It never fired once in three weeks of logs — I just hadn't stepped on it.
Got a line in your logs you've never once questioned? Start by counting how many times it prints.