Skip to content

Progress updates flood the UI handler, which is the top ANR - #194

Merged
xroche merged 5 commits into
masterfrom
fix-progress-flood
Sep 9, 2026
Merged

xroche merged 5 commits into
masterfrom
fix-progress-flood

Conversation

@xroche

@xroche xroche commented Sep 9, 2026

Copy link
Copy Markdown
Owner

setProgressLines posted one Runnable per engine refresh, and each one walks the layout, measures every line and parses it with Html.fromHtml. The engine produces frames faster than the main thread draws them, so the queue grew without limit.

The newest lines now sit in a ProgressCoalescer behind one reusable task, and a refresh arriving while a task is pending replaces the payload. Nothing is lost, because each frame replaces the whole progress pane.

The task takes the payload and disarms in one synchronized step before drawing. A refresh landing mid-draw then posts a task of its own, so the final frame still reaches the screen. Three mutants of the coalescer each fail the new tests.

Moving Html.fromHtml off the main thread is a separate change.

Closes #187

xroche and others added 5 commits September 9, 2026 10:52
setProgressLines posted a new Runnable for every engine refresh. Each one
walks the layout, measures every line and parses it with Html.fromHtml.
The engine queued frames faster than the main thread drew them. The
queue grew without limit, which is the ANR, and the frames waiting in it
are the OutOfMemoryError. Play attributes 7 of 11 ANR clusters and 2 crash
clusters to setProgressLinesInternal.

Keep the newest lines in a ProgressCoalescer and post one reusable task.
A refresh arriving while a task is pending replaces the payload rather
than adding a task. That loses nothing, because each frame replaces the
whole progress pane, so a frame that is already superseded has nothing
left to show.

The task takes the payload and disarms in one synchronized step, before
it draws. A refresh landing mid-draw therefore finds the coalescer idle
and posts a task of its own. So the last frame of a crawl is drawn even
when it arrives while its predecessor is still on screen. Handler.post is
also checked, because a false return on a dead looper would leave the
latch armed and freeze progress for good.

Html.fromHtml still runs per line per frame. Coalescing bounds how often,
and moving the parse off the main thread is a separate change.

Closes #187

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
The failed-post branch called take() and dropped the payload. take()
promises a payload, so a reader had to work out that dropping it is safe.
The payload dropped need not even be the one that caller offered. A
refresh landing between the offer and the post's false return replaces it
first. disarm() says what the branch means, and take() now uses it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
The drawing task reads an empty coalescer as a null payload, so a null
frame would be drawn as nothing. Nothing tested the refusal.

The rest is shape. The counter is postedTasks, not queued, because the
name has to say whose queue it stands for. The schedule count and the
frame count are named, and "frame " + i has a helper. startDraw() no
longer hides an assertion inside the fake, and the last test drives the
fake instead of hand-rolling a second copy of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
armed was true exactly when pending was non-null, so two fields carried
one state and only a reading of the code held them in agreement. A probe
over every sequence of offer, take and disarm up to length seven found
they never disagree. So pending == null now means no task is owed.

The null payload that offer refuses stops being a dead guard under that
change. A null would read as nothing pending and let a second task be
posted over the first, and the javadoc says so.

offer returning true meant "you owe a post", which reads backwards
against Queue.offer, where true means accepted. offerNeedsPost says which
way it runs at the call site.

Two elements stay defensive, and each comment now says why it cannot
fire. post() refuses only once the Looper quits, and take() answers null
only after a disarm that follows a post.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
Three summaries opened with a noun phrase, which reads as a javadoc
convention and hides that no sentence is there. The class, the field and
the test class now start on a verb.

The class doc also ran to 34 words in one sentence, so it is two. Take()
no longer nests a clause between the two objects of "gives".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
@xroche
xroche enabled auto-merge (squash) September 9, 2026 09:24
@xroche
xroche merged commit 049cb1a into master Sep 9, 2026
7 checks passed
@xroche
xroche deleted the fix-progress-flood branch September 9, 2026 09:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Progress updates flood the UI handler, which is the top ANR and an OutOfMemoryError

1 participant