Skip to content

Commit 489ea5e

Browse files
committed
Many animated images in the web page can freeze its rendering
https://bugs.webkit.org/show_bug.cgi?id=308730 Reviewed by NOBODY (OOPS!). Each BitmapImageSource that decoded asynchronously created its own WorkQueue. That queue ran one work item that looped on a blocking SynchronizedFixedQueue::dequeue() until stop(). The queue is stopped only when it is idle and the image is static or its animation has stopped. So every animating image, and every removed image with a pending frame request, held a thread that waited for future frames. With many animated images, these waiting workers accumulate until later images can no longer be decoded and the page stops rendering new frames. AVIF decoding also uses GCD internally. Allowing an unbounded number of image decodes can exhaust the available GCD worker threads when the platform decoder dispatches its own work, preventing decoding from making progress. Bound concurrent image decoding as well as eliminating workers that wait for future frame requests. Replace the long-running loop with one finite work item per decode request: - Requests for a single image still run serially, including across stop() and restart. - A shared scheduler runs ready images on at most 8 reusable WorkQueue workers, giving other ready images a turn between frames. An empty image queue never keeps a worker waiting, and queue pressure never turns into unbounded concurrent decodes. - stop() increments a generation counter. Stale requests skip decoding, and their completions are ignored. A completion is still posted to the creation thread, so the source and the work queue are released there. - ImageFrameWorkQueue keeps the decoder between requests until stop(), as the old loop did. - The minimum decoding duration for testing is captured at dispatch time instead of being read on the decoding thread. Also, when pruning decoded data, keep an already decoded next frame while the animation timer is pending. Otherwise the frame would be decoded synchronously when the timer fires. Keep the decoder while decoded frames are still in use. Tests: fast/images/async-image-decoding-concurrent-images.html fast/images/async-image-decoding-removed-images.html * LayoutTests/TestExpectations: * LayoutTests/fast/images/async-image-decoding-concurrent-images-expected.txt: Added. * LayoutTests/fast/images/async-image-decoding-concurrent-images.html: Added. * LayoutTests/fast/images/async-image-decoding-removed-images-expected.txt: Added. * LayoutTests/fast/images/async-image-decoding-removed-images.html: Added. * Source/WebCore/platform/graphics/BitmapImageSource.cpp: (WebCore::BitmapImageSource::destroyDecodedFrames): (WebCore::BitmapImageSource::destroyDecodedData): * Source/WebCore/platform/graphics/ImageFrameAnimator.h: (WebCore::ImageFrameAnimator::nextFrameIndex const): * Source/WebCore/platform/graphics/ImageFrameWorkQueue.cpp: (WebCore::ImageDecodingQueue::create): (WebCore::ImageDecodingQueue::WTF_GUARDED_BY_LOCK): (WebCore::ImageDecodingScheduler::singleton): (WebCore::ImageDecodingScheduler::dispatch): (WebCore::ImageDecodingScheduler::WTF_GUARDED_BY_LOCK): (WebCore::ImageDecodingQueue::dispatch): (WebCore::ImageDecodingQueue::runOneRequest): (WebCore::ImageFrameWorkQueue::ImageFrameWorkQueue): (WebCore::ImageFrameWorkQueue::dispatch): (WebCore::ImageFrameWorkQueue::stop): (WebCore::ImageFrameWorkQueue::requestQueue): Deleted. (WebCore::ImageFrameWorkQueue::start): Deleted. * Source/WebCore/platform/graphics/ImageFrameWorkQueue.h: (WebCore::ImageFrameWorkQueue::minimumDecodingDurationForTesting const): Deleted.
1 parent 88858fa commit 489ea5e

9 files changed

Lines changed: 289 additions & 75 deletions

‎LayoutTests/TestExpectations‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,9 @@ fast/frames/frame-append-body-child-crash.html [ Slow ]
344344
[ Debug ] fast/frames/lots-of-objects.html [ Slow ]
345345
[ Debug ] fast/frames/lots-of-iframes.html [ Slow ]
346346

347+
# The 160 sequential batches each require at least 100 ms of decoding.
348+
webkit.org/b/308730 fast/images/async-image-decoding-removed-images.html [ Slow ]
349+
347350
# Builds a 10000 link aria-labelledby chain, which has to be long enough to overflow the stack on
348351
# release builds, so it is unavoidably slow to walk in a debug build.
349352
[ Debug ] accessibility/accessible-name-chain-depth-no-crash.html [ Slow ]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Many independent animated images must finish asynchronous decoding without exhausting decoding workers.
2+
3+
On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
4+
5+
6+
PASS completedImages is 600
7+
PASS successfullyParsed is true
8+
9+
TEST COMPLETE
10+
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<script src="../../resources/js-test.js"></script>
5+
<style>
6+
#images { position: fixed; top: 0; right: 0; display: grid; grid-template-columns: repeat(40, 10px); }
7+
#images img { width: 10px; height: 10px; }
8+
</style>
9+
</head>
10+
<body>
11+
<div id="images"></div>
12+
<script>
13+
description("Many independent animated images must finish asynchronous decoding without exhausting decoding workers.");
14+
jsTestIsAsync = true;
15+
var completedImages = 0;
16+
17+
(async () => {
18+
internals.clearMemoryCache();
19+
const images = await Promise.all(Array.from({ length: 600 }, async (_, index) => {
20+
const image = new Image;
21+
await new Promise((resolve, reject) => {
22+
image.onload = resolve;
23+
image.onerror = reject;
24+
// Distinct URLs prevent the images from sharing one decoder.
25+
image.src = `resources/animated-red-green-blue-repeat-infinite.gif?concurrent-${index}`;
26+
});
27+
internals.setAsyncDecodingEnabledForTesting(image, true);
28+
return image;
29+
}));
30+
await Promise.all(images.map(image => image.decode()));
31+
for (const image of images)
32+
internals.setImageFrameDecodingDuration(image, 0.05);
33+
document.getElementById("images").append(...images);
34+
document.body.offsetHeight;
35+
testRunner.display();
36+
// Keep the animations visible while all their next-frame requests finish.
37+
await Promise.all(images.map(image => image.decode()));
38+
completedImages = images.filter(image => internals.imageDecodeCount(image) >= 2).length;
39+
shouldBe("completedImages", "600");
40+
})().catch(error => testFailed(String(error))).finally(() => {
41+
document.getElementById("images").remove();
42+
finishJSTest();
43+
});
44+
</script>
45+
</body>
46+
</html>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Repeatedly removing small batches during animation decoding must not accumulate waiting workers and stall later images.
2+
3+
On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
4+
5+
6+
PASS completedBatches is 160
7+
PASS successfullyParsed is true
8+
9+
TEST COMPLETE
10+
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<script src="../../resources/js-test.js"></script>
5+
</head>
6+
<body>
7+
<div id="images"></div>
8+
<script>
9+
description("Repeatedly removing small batches during animation decoding must not accumulate waiting workers and stall later images.");
10+
jsTestIsAsync = true;
11+
var completedBatches = 0;
12+
13+
(async () => {
14+
internals.clearMemoryCache();
15+
for (let batch = 0; batch < 160; ++batch) {
16+
const images = await Promise.all(Array.from({ length: 4 }, async (_, index) => {
17+
const image = new Image;
18+
image.decoding = "async";
19+
await new Promise((resolve, reject) => {
20+
image.onload = resolve;
21+
image.onerror = reject;
22+
image.src = `resources/animated-red-green-blue-repeat-1.gif?removed-${batch}-${index}`;
23+
});
24+
internals.setAsyncDecodingEnabledForTesting(image, true);
25+
return image;
26+
}));
27+
await Promise.all(images.map(image => image.decode()));
28+
for (const image of images)
29+
internals.setImageFrameDecodingDuration(image, 0.1);
30+
document.getElementById("images").append(...images);
31+
document.body.offsetHeight;
32+
// Attaching an image can start its timer without requesting a frame.
33+
// Explicitly request the next frame before removing the renderer.
34+
for (const image of images)
35+
internals.resetImageAnimation(image);
36+
const decoding = Promise.all(images.map(image => image.decode()));
37+
await new Promise(resolve => setTimeout(resolve, 5));
38+
if (!images.every(image => internals.imageDecodeCount(image) === 1))
39+
throw new Error("The animation frame must still be decoding when the images are removed.");
40+
document.getElementById("images").replaceChildren();
41+
document.body.offsetHeight;
42+
testRunner.display();
43+
// Wait for this batch to finish before creating another. Only four
44+
// images are attached at once, and earlier batches are not retained.
45+
await decoding;
46+
if (!images.every(image => internals.imageDecodeCount(image) >= 2))
47+
throw new Error("The removed images must finish asynchronous decoding.");
48+
++completedBatches;
49+
}
50+
shouldBe("completedBatches", "160");
51+
})().catch(error => testFailed(String(error))).finally(() => {
52+
document.getElementById("images").remove();
53+
finishJSTest();
54+
});
55+
</script>
56+
</body>
57+
</html>

‎Source/WebCore/platform/graphics/BitmapImageSource.cpp‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,15 @@ void BitmapImageSource::destroyDecodedFrames(bool destroyAll)
132132

133133
unsigned primaryFrameIndex = this->primaryFrameIndex();
134134
unsigned currentFrameIndex = this->currentFrameIndex();
135+
std::optional<unsigned> nextFrameIndex;
136+
if (m_frameAnimator && m_frameAnimator->isAnimating())
137+
nextFrameIndex = m_frameAnimator->nextFrameIndex();
135138
unsigned decodedSize = 0;
136139

137140
for (unsigned index = 0, framesSize = m_frames.size(); index < framesSize; ++index) {
138-
if (!canDestroyDecodedData && (index == primaryFrameIndex || index == currentFrameIndex))
141+
// A decoded next frame is still needed while the animation timer is pending.
142+
// Dropping it here would force a synchronous decode when the timer advances.
143+
if (!canDestroyDecodedData && (index == primaryFrameIndex || index == currentFrameIndex || index == nextFrameIndex))
139144
continue;
140145

141146
if (!destroyAll && index > currentFrameIndex)
@@ -151,9 +156,8 @@ void BitmapImageSource::destroyDecodedData(bool destroyAll)
151156
{
152157
destroyDecodedFrames(destroyAll);
153158

154-
// There's no need to throw away the decoder unless we're explicitly asked
155-
// to destroy all of the frames.
156-
if (destroyAll && isDecodingWorkQueueIdle())
159+
// Keep the decoder while decoded frames are still needed by the image.
160+
if (destroyAll && isDecodingWorkQueueIdle() && !m_decodedSize)
157161
resetData();
158162
else
159163
clearFrameBufferCache();

‎Source/WebCore/platform/graphics/ImageFrameAnimator.h‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class ImageFrameAnimator : public CanMakeWeakPtr<ImageFrameAnimator> {
5858

5959
bool hasEverAnimated() const { return !!m_desiredFrameStartTime; }
6060
unsigned currentFrameIndex() const { return m_currentFrameIndex; }
61+
unsigned nextFrameIndex() const { return (m_currentFrameIndex + 1) % m_frameCount; }
6162

6263
void dump(TextStream&) const;
6364

@@ -68,8 +69,6 @@ class ImageFrameAnimator : public CanMakeWeakPtr<ImageFrameAnimator> {
6869
void clearTimer();
6970
void timerFired();
7071

71-
unsigned nextFrameIndex() const { return (m_currentFrameIndex + 1) % m_frameCount; }
72-
7372
UTF8CString sourceUTF8() const;
7473

7574
ThreadSafeWeakRef<BitmapImageSource> m_source;

0 commit comments

Comments
 (0)