Visitar URL original
fix(imaging): Release cached decodes, keep encoded bytes instead by mikernet · Pull Request #25559 · unoplatform/uno · GitHub
Skip to content

fix(imaging): Release cached decodes, keep encoded bytes instead - #25559

Open
mikernet wants to merge 16 commits into
unoplatform:masterfrom
mikernet:dev/mikernet/bitmap-cache-release-25128
Open

mikernet wants to merge 16 commits into
unoplatform:masterfrom
mikernet:dev/mikernet/bitmap-cache-release-25128

Conversation

@mikernet

@mikernet mikernet commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

GitHub Issue: closes #25128

PR Type:

🐞 Bugfix (it also adds a setting, MaxBitmapImageCacheBytes)

What changed? 🚀

With the bitmap cache on (the default), BitmapImages that load the same URI and decode size share one decode. A static LRU of MaxBitmapImageCacheCount (100) entries owned it whether anything displayed it or not, and a 4000×2667 photo decoded at full size holds 43 MB. Decodes it evicted, and loads whose requesters were all cancelled, were left to finalization (#25128). #25124 releases the decodes a source owns once nothing displays them. This PR does the same for shared decodes, and keeps encoded bytes instead.

  • Shared decodes are released like owned ones. A shared decode counts its uses: the opens waiting for it, and the sources whose subscribers draw it or were given it to draw. With none left, it waits on fix(imaging): Release a decoded image when nothing displays it #25124's list (1 s, within MaxUndisplayedImageBytes), then is freed and leaves the cache. Anything that shows it again before then keeps it.
  • The cache doesn't own its decodes, as WinUI's provider store doesn't own its ImageCache. It holds them weakly. A source collected while still subscribed (a brush collected with its source never ends its subscription, as its lease only ends one whose source outlives it) has its uses ended once it is collected.
  • A BitmapImage keeps the bytes it downloaded over http or https for as long as its UriSource stays, whatever the caching headers said, as a WinUI BitmapImage keeps its ImageCache and so its encoded data. Decoding it again after its release needs no network, so it doesn't fail offline after it opened, also when it joined a shared load another BitmapImage started that failed. This also holds with IgnoreImageCache. Assigning an equal Uri again reloads the source and drops them: with the cache on, the reopen joins the live shared decode or the cache and gets the bytes back without a download; only an IgnoreImageCache source downloads again. A BitmapImage that opens while EnableBitmapImageCache is off keeps none; bytes already kept stay until each source opens again or changes.
  • The cache keeps the encoded bytes of http and https downloads for other decode sizes and other BitmapImages, as WinUI's system HTTP cache would: least recently used dropped first, and dropped after 5 minutes unused, by a timer, or sooner under memory pressure at gen 2 collections (3, 1 or 0.5 minutes above low pressure, as the LRUCache it replaces did; not on iOS or tvOS, which have no MemoryManager). Not kept: bytes that didn't decode, unsuccessful or redirected responses (WebAssembly doesn't expose redirects), and Cache-Control: no-store or no-cache, or Pragma: no-cache without Cache-Control. Bytes kept are used only while the response is fresh: for its max-age, or else its Expires less its Date, less its Age (an invalid Expires, such as 0, means already expired; a Cache-Control that doesn't parse is not reused). App assets and local files are read again. IgnoreImageCache bypasses the sharing and the cache.

Public API:

  • FeatureConfiguration.Image.MaxBitmapImageCacheBytes (new) bounds the bytes in the cache: 64 MB on desktop, 32 MB on Android, iOS, tvOS and WebAssembly. The bytes a BitmapImage keeps for itself are outside it, as on WinUI.
  • MaxBitmapImageCacheCount is [Obsolete] and has no effect, as the decodes it counted are no longer kept.

WinUI keeps one ImageCache per URI, with the encoded bits and decoded copies, for as long as a BitmapImage uses it (CImageSource::m_imageCache, ImageCache::m_encodedImageData). A later request downloads again through UrlMon, whose disk cache follows the caching headers (MUX Reference: ImageCache.h, ImageProvider.cpp, imagesource.cpp, windwnreq.cpp). Uno's HttpClient has no such cache on desktop, so the encoded cache stands in for it.

Behavior to be aware of

  • An image shown again after the 1 s wait is decoded again, from its file or the kept bytes. A decode size makes that cheaper.
  • A live BitmapImage keeps its encoded bytes, as on WinUI: the whole downloaded file, whatever the decode size. Without a decode size that is usually a fraction of the full-size decode; with one it can be larger than the decode. On WebAssembly, whose memory is capped, an app that keeps many large BitmapImages alive keeps their bytes too. Another BitmapImage of the same URI, once that decode is released, or at another decode size, gets them only through the cache, so if the response was not reusable it downloads again, where WinUI would share the live ImageCache.
  • An ImageBrush keeps its decode for as long as it lives, as in fix(imaging): Release a decoded image when nothing displays it #25124.
  • Setting MaxBitmapImageCacheCount raises warning CS0618.

Open question: the cache defaults hold about 8 to 32 full-size JPEG photos. Would you prefer other values, or one value everywhere? A disk cache, like the one WinUI gets from the system, could be a follow-up.

Tests

Given_Image_Release (Skia). Tests marked (desktop) need a loopback server. The collection and encoded-cache tests run on desktop and WebAssembly, where a stand-in for fetch serves the image, and the others on every Skia target.

Test list
  • Release and hold:
    • When_Shared_Decode_Undisplayed_Then_Released
    • When_Shared_Decode_Undisplayed_Then_Counted_With_Owned_Ones
    • When_UriSource_Changed_Then_Previous_Shared_Decode_Released
    • When_Shared_Decode_Given_But_Not_Drawn_Then_Kept (desktop)
    • When_Image_Returns_While_Replacement_Downloads_Then_Shared_Pixels_Kept (desktop)
    • When_Source_Changed_During_Notification_Then_Skipped_Subscriber_Keeps_Shared_Decode (desktop)
  • Superseded, cancelled or failed opens give up their shared result:
    • When_PixelWidth_Handler_Supersedes_Shared_Open_Then_Decode_Released
    • When_PixelWidth_Handler_Switches_To_Cached_Source_Then_Superseded_Shared_Decode_Released (desktop)
    • When_ImageOpened_Handler_Throws_Then_Shared_Decode_Released
    • When_Shared_Load_Requesters_All_Cancelled_Then_Result_Released (desktop)
  • Collection (desktop and WebAssembly):
    • When_Source_Collected_While_Subscribed_Then_Shared_Decode_Not_Kept
    • When_Source_Collected_While_Sharing_With_Live_One_Then_Decode_Still_Released
    • When_Last_Sharing_Source_Collected_While_Other_Idles_Then_Decode_Released
  • Encoded bytes (desktop and WebAssembly):
    • When_Released_Image_Shown_Again_Then_Its_Source_Decodes_Its_Own_Bytes (a no-store response, a full cache, IgnoreImageCache)
    • When_Shared_Decode_Released_Then_Decoded_Again_Without_Download
    • When_Response_Not_Reusable_Then_Downloaded_Again (a row per header and status, Expires and Age included)
    • When_Response_No_Longer_Fresh_Then_Downloaded_Again (a short and a long max-age)
    • When_Equal_UriSource_Assigned_Then_Downloaded_Again
    • When_Shared_Load_Joined_Fails_Then_Source_Decodes_Its_Own_Bytes (desktop)
    • When_Response_Redirected_Then_Downloaded_Again (desktop)
    • When_Encoded_Cache_Full_Then_Least_Recently_Used_Dropped
    • When_Encoded_Cache_Lowered_Or_Disabled_Then_Kept_Bytes_Dropped
    • When_Encoded_Bytes_Unused_Past_Expiry_Then_Downloaded_Again
    • When_Encoded_Bytes_Unused_Past_Expiry_Then_Dropped_Without_Cache_Use
    • When_Memory_Pressure_Rises_Then_Encoded_Bytes_Dropped_Sooner (the trim levels; the gen 2 registration itself isn't covered by a test)
  • fix(imaging): Release a decoded image when nothing displays it #25124's tests:
    • cache-on rows for the moved, drawn-brush, brush-replaced and unloaded-between-delivery-and-measure tests
    • the two cached-handler tests and the failed-source test also check that the shared decode is released
    • the two that assumed a shared decode is never released use a long delay, or a decode size of their own
    • cache-on rows for the Given_Image test of an auto-sized Image shown while its source opens again: a released shared decode keeps its pixel size (the cache is turned off before it reopens, so it downloads again rather than decoding the bytes its source kept)

Each new test fails with its product change reverted. The less obvious cases:

  • without the take-back, or with a use counted only for a source's current data, the returning-Image test and the cache-on row of fix(imaging): Release a decoded image when nothing displays it #25124's moved-between-delivery-and-measure test fail
  • with a collected source's uses never ended, the collected-and-live and last-user-collected tests fail
  • with a strong cache table, the collected-source test fails
  • with every response treated as reusable, the header and status rows fail; with Expires ignored, its past and 0 rows fail; with Age ignored for max-age or for Expires, the matching Age row fails; with no freshness kept per entry, the short max-age row fails
  • with a source keeping no bytes of its own, every row of the own-bytes test fails; with them kept through an equal Uri assigned, the reassign test fails; without decoding them after a failed shared load, the joined-load test fails
  • with the expiry timer doing nothing, the idle-expiry test fails; with over-limit pressure trimming like low, the pressure test fails

The image classes run: Given_Image_Release, Given_BitmapImage, Given_Image, Given_ImageSource, Given_ImageBrush, Given_BitmapSource, Given_WriteableBitmap, Given_RenderTargetBitmap and Given_BitmapIcon.

Runs, rebased on master:

  • Skia desktop, image classes: 185 passed, 5 skipped, 1 inconclusive and 1 failed.
  • WebAssembly, the same classes: 141 passed, 41 skipped (desktop-only), 2 inconclusive and the same failure.
  • After the last freshness changes, Given_Image_Release: desktop 102 passed, WebAssembly 66 passed and 32 skipped (desktop-only).

The failure, When_WriteableBitmap_Assigned_With_Data_Present, also fails with master's image code on this machine (125% scaling).

PR Checklist ✅

Copilot AI balanced review requested due to automatic review settings October 17, 2026 02:31
@github-actions github-actions Bot added area/skia ✏️ Categorizes an issue or PR as relevant to Skia kind/documentation labels Oct 17, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Two unresolved memory-retention issues affect shared decode cleanup and cache disabling.

2 open issues
What changed in this PR

Addresses #25128 by extending #25124’s deterministic image release to shared decodes and retaining encoded downloads instead.

Changes:

  • Tracks shared decode usage and schedules unused pixels for release.
  • Adds a byte-bounded encoded-image cache with response reuse checks.
  • Updates configuration, documentation, and runtime test support.
File Description
src/​Uno.WinRT/​Helpers/​LRUCache.cs Removes the previous cache implementation.
src/​Uno.WinRT/​Helpers/​AppDataUriEvaluator.cs Reports download reuse eligibility.
src/​Uno.WinRT/​Collections/​LruCache.cs Removes an obsolete replacement reference from a comment.
src/​Uno.UI/​UI/​Xaml/​Media/​SharedImageDecode.cs Tracks shared decode lifetime.
src/​Uno.UI/​UI/​Xaml/​Media/​PendingImageReleases.cs Schedules delayed, byte-bounded releases.
src/​Uno.UI/​UI/​Xaml/​Media/​Imaging/​RenderTargetBitmap.cs Preserves cleanup for cancelled opens.
src/​Uno.UI/​UI/​Xaml/​Media/​Imaging/​ImageSourceHelpers.cs Reuses encoded downloads during decoding.
src/​Uno.UI/​UI/​Xaml/​Media/​Imaging/​EncodedImageCache.cs Adds byte-bounded encoded storage.
src/​Uno.UI/​UI/​Xaml/​Media/​Imaging/​BitmapSource.cs Versions stream-source changes.
src/​Uno.UI/​UI/​Xaml/​Media/​Imaging/​BitmapImage.cs Integrates shared decodes and event versioning.
src/​Uno.UI/​UI/​Xaml/​Media/​ImageSource.cs Updates disposal and release handling.
src/​Uno.UI/​UI/​Xaml/​Media/​ImageSource.crossruntime.cs Tracks subscriber-held pixels and ownership.
src/​Uno.UI/​UI/​Xaml/​Media/​ImageData.cs Carries shared decode ownership.
src/​Uno.UI/​UI/​Xaml/​Media/​ImageBrush.cs Removes redundant URI subscriptions.
src/​Uno.UI/​UI/​Xaml/​Media/​ImageBrush.crossruntime.cs Handles source replacement and reentrancy.
src/​Uno.UI/​UI/​Xaml/​Internal/​CoreServices.cs Integrates releases with layout passes.
src/​Uno.UI/​UI/​Xaml/​Controls/​Image/​Image.cs Manages subscriptions across load and unload.
src/​Uno.UI/​FeatureConfiguration.cs Adds memory limits and deprecates entry counts.
src/​Uno.UI.RuntimeTests/​Microsoft/​UI/​Xaml/​Media/​Imaging/​Given_BitmapImage.cs Adapts sharing tests to loaded images.
src/​Uno.UI.RuntimeTests/​Helpers/​GatedImageServer.cs Extracts and extends the HTTP test server.
src/​Uno.UI.Composition/​Composition/​CompositionImageSurface.skia.cs Counts decoded bytes and releases frames.
doc/​articles/​feature-flags.md Documents image lifetime and cache settings.

Review effort: Balanced

@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-25559/docs/index.html

Copilot AI balanced review requested due to automatic review settings October 17, 2026 03:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Shared ownership, finalizer handoff, and reentrant cross-platform image lifecycles warrant human review of the implementation and runtime evidence.

No open issues 🎉

2 resolved since last review

Review effort: Balanced

@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-25559/docs/index.html

mikernet added a commit to Singulink/uno that referenced this pull request Oct 17, 2026
Port of unoplatform#25559 (e61fa84, 98c5b27, e1e1955,
2d0e812, 6f7e0bb and d89edf0), the follow-up to unoplatform#25128, on top
of the unoplatform#25124 port.

With the bitmap cache on, the decode shared by the BitmapImages that
load the same uri and decode size was owned by an LRU of
MaxBitmapImageCacheCount (100) entries, released by no source.
- SharedImageDecode counts a shared decode's uses (the opens waiting for
  it and the sources whose subscribers draw it or were given it). Once
  it has none, it waits on PendingImageReleases like an owned decode
  (1 s and MaxUndisplayedImageBytes, oldest first), then frees its
  frames and leaves the cache. Sources are held weakly, and a collected
  source's uses are ended on the UI thread by a finalizable object only
  it holds. A subscriber back with a shared decode its source replaced
  uses it again until it draws the replacement.
- EncodedImageCache keeps the encoded bytes of images downloaded over
  http or https, bounded by the new MaxBitmapImageCacheBytes (64 MB on
  desktop, 32 MB on Android, iOS, tvOS and WebAssembly), least recently
  used first, and drops bytes unused for 5 minutes. Only successful,
  unredirected responses without no-store, no-cache or max-age=0 are
  kept. Turning EnableBitmapImageCache off drops them, checked under the
  cache's lock. AppDataUriEvaluator.DownloadAsync reports whether a
  response may be reused.
- MaxBitmapImageCacheCount is obsolete and has no effect.
- feature-flags.md describes the bitmap cache settings.
- Tests: Given_Image_Release (76 cases), GatedImageServer's extra
  header, status and redirect, and on WebAssembly a stand-in for fetch.

6.8 differences:
- As in the unoplatform#25124 port: SkiaCompositionSurface instead of
  CompositionImageSurface (a shared decode counts its frames only), the
  tests' TrackingDecoder over FrameProviderFactory, and SurfaceOf in
  ImageSource.crossruntime.cs.
- SharedImageDecode is crossruntime, like ImageSource and
  PendingImageReleases, so its surface access, byte count and release
  are Skia only. ImageData.Share is crossruntime too; Android and iOS
  never shared data.
- The decode split (ReadAllAsync, DecodeAsCompositionSurface) keeps the
  ImageSourceHelpers of the unoplatform#25542 and unoplatform#25544 ports: the browser decode
  into native memory, and MayBeAnimated from the bytes in place of
  upstream's attemptLoadingWithBrowserCanvasApi. A cached buffer is
  decoded through a read-only stream.
- Uno.UWP keeps LRUCache, which TextBlock (Android), UnicodeText (Skia)
  and Shape (native WebAssembly) still use on 6.8.
Copilot AI balanced review requested due to automatic review settings October 21, 2026 20:33
@mikernet
mikernet force-pushed the dev/mikernet/bitmap-cache-release-25128 branch from d89edf0 to 6cf79db Compare October 21, 2026 20:33
mikernet added a commit to Singulink/uno that referenced this pull request Oct 21, 2026
Port of unoplatform#25124's bfa20af and 129365e, with the
part of unoplatform#25559's fc608f4 that keeps a released shared decode's size.
An auto-sized Image showing a source that released its pixels, because
nothing displayed them for the release delay, laid out at 0×0 until the
source decoded them again. With the bitmap cache off, a URI downloads
again meanwhile; with it on, a released shared decode decodes again, or
downloads again when its response could not be kept. This happened to
an Image coming back to the tree, and to another Image given the
source, such as a recycled list item.

A source now keeps the pixel size of what it released until it opens
again or changes, a released shared decode keeps its own, and an Image
measures with it meanwhile, as WinUI keeps the image's size. A source
that fails or is cleared still leaves the Image empty. An Image never
added to the tree doesn't open its source, as on WinUI, and a test now
covers it.

6.8 differences:
- Arrange scaled the brush by the surface's image size, which a
  released surface, or an Image with no surface yet, doesn't have: it
  now keeps its last scale until the image is there.
- The SVG path is unchanged, as 6.8 keeps an SVG source's parsed
  document.
- 6.8 has no texture-backed surfaces, so 772c328 isn't needed.
- The size test runs on Skia desktop and the off-tree test on Skia;
  both pass on WinUI on master. The native renderers open their sources
  themselves.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Shared ownership, reentrant callbacks, and finalizer-driven cleanup warrant human review, with post-rebase WebAssembly validation still outstanding.

No open issues 🎉

Review effort: Balanced

@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-25559/docs/index.html

mikernet added a commit to Singulink/uno that referenced this pull request Oct 21, 2026
Port of unoplatform#25633 (9f71cb9 and 9fcac22), with the
shared-source Image test of unoplatform#25124's fa4a240, whose helpers its
tests use.

A source that outlives its brushes, such as an icon in a static field
or in the app resources, kept every ImageBrush that ever painted it
alive, and with each one the element it painted. The source held the
brush's subscription and its Invalidated handler strongly. It now
holds both weakly: the brush keeps a lease on its subscription, whose
finalizer ends the subscription once the brush is collected, so the
source can release the pixels it owns; an open in flight keeps the
brushes waiting for it alive, but not one that left the source; and
Invalidated becomes a weak RegisterInvalidated registration.

6.8 differences:
- The open's completion also ends a shared decode's open use (the
  unoplatform#25559 port), so the keep-alive release sits inside that try.
- The native Image's WriteableBitmap handler (Image.cs) uses the weak
  registration too. The native WebAssembly brush keeps its own
  per-element subscription, unchanged.
- The test surface type is SkiaCompositionSurface.
@unoplatform unoplatform deleted a comment from azure-pipelines Bot Nov 1, 2026
@unoplatform unoplatform deleted a comment from azure-pipelines Bot Nov 1, 2026
@agneszitte

Copy link
Copy Markdown
Member

@MartinZikmund can you help follow up on this PR please

@MartinZikmund MartinZikmund left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed by reading the code against this PR's head, current master and the WinUI C++ sources. I haven't built or run it.

The diff shown here includes #25124, which has since merged. This PR's own product change is about +644/-336, so a rebase will make it much easier to review. The WinUI symbols it cites exist, including sc_imageSurfaceWrapperReleaseDelayMilliseconds = 1000 (corep.h:2096) and ImageCache::OnRequestReleasing, which only drops the request.

  1. Memory-pressure trimming is lost. The deleted LRUCache registered a Gen2 trim callback that shortened its retention under memory pressure. EncodedImageCache only trims when it is next used, so an idle app can keep up to 64 MB (32 MB on WebAssembly) indefinitely, and the 5-minute expiry doesn't run until then. I'd hook it to the same trim mechanism.
  2. Difference from WinUI on re-display. WinUI's ImageCache keeps m_encodedImageData whatever the caching headers say, for as long as a BitmapImage uses it (ImageCache.h:161). Here, a live BitmapImage whose response was no-store or whose bytes were evicted downloads again once its decode has been released, so offline it raises ImageFailed after an earlier ImageOpened, which WinUI wouldn't do. The description covers the behavior but not that it differs. Either keep the bytes while the source is alive, within some budget, or state this as a deliberate difference.
  3. Rebase. Open/Complete conflicts with master's KeepWeakCallbacksWhileOpening (9f71cb9). "An ImageBrush keeps its subscription for its lifetime" is also out of date, because brushes now use SubscribeWeakly with a lease. The lease's finalizer and SharedImageDecode.User look like they cover complementary cases (source alive vs. collected with the brush), but the collection tests should be re-run on the rebased code. This also conflicts with #25548 in feature-flags.md and FeatureConfiguration.cs.
  4. Smaller points.
    • DownloadAsync treats Expires: 0 or a past Expires without Cache-Control as reusable. The docs do say other freshness rules aren't checked, but this one is common and cheap to add.
    • If DispatcherQueue.TryEnqueue fails in Open, Complete never runs, so the open's use of a shared decode is never ended. This should only matter at shutdown.

@MartinZikmund

Copy link
Copy Markdown
Member

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines will not run the associated pipelines, because the pull request was updated after the run command was issued. Review the pull request again and issue a new run command.

@MartinZikmund

Copy link
Copy Markdown
Member

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines will not run the associated pipelines, because the pull request was updated after the run command was issued. Review the pull request again and issue a new run command.

@mergify

mergify Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue). If it does not match the queue conditions yet, it enters the queue once it does.

  • Queue this pull request

@Xiaoy312

Copy link
Copy Markdown
Contributor

Hi @mikernet, do you intend to keep finishing this PR, or would you like the uno-team to take over here?

@mikernet

mikernet commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor Author

@Xiaoy312 Reply on other PR applies to all my PRs with pending feedback to address: I was intending to get through all the PR feedback in the next day or two, unless you want to finish it sooner - up to you :)

Just been a busy week but I'll have some time cleared up likely tonight or tomorrow to get through most/all of it, depending on how much there is to do, haven't had a chance to look at it more than a cursory glance.

@Xiaoy312

Copy link
Copy Markdown
Contributor

Glad to hear back from you. We will let you to continue at your own pace.

With the bitmap cache on, the decode shared by the BitmapImages that
load the same uri and decode size was owned by an LRU of
MaxBitmapImageCacheCount (100) entries. No source released it, and the
LRU kept it whether or not anything displayed it. An entry the LRU
evicted, or a shared load whose requesters were all cancelled, was left
to finalization (unoplatform#25128).

A shared decode now counts its uses: the opens waiting for it, and the
sources whose subscribers draw it or were given it to draw. Once it has
none, it waits on the same pending-release list as the decodes sources
own (1 s and MaxUndisplayedImageBytes, oldest first). When released, its
frames are freed and it leaves the cache, and a source that still holds
it opens again on its next subscriber. It keeps its pixel size, which an
Image showing that source measures with until then, as for the decodes
sources own.

An open hands its use over to its source once the source counts its own
use. Any other way out of the open (cancelled, failed, superseded, a
handler that throws) ends it, so nothing can release the result in
between, and nothing keeps it for good. A load whose requesters were all
cancelled keeps running for later ones, as since unoplatform#25101, and its result
then waits like any other.

The sharing follows WinUI's ImageCache, which leaves the provider's
store once the last BitmapImage using it lets it go (MUX Reference:
ImageProvider::EnsureCacheEntry, ImageProvider::RemoveImageCache). Like
that store, the cache does not own its decodes. An ImageBrush keeps its
subscription for its lifetime, so a source can be collected while it
still counts as a user. Sources are therefore held weakly, and a
collected one is dropped the next time the decode's uses change.

A subscriber that comes back still drawing a shared decode its source
replaced while it was away uses it again until it draws what replaced
it, as unoplatform#25124 lets it take back replaced pixels it owns.

The LRUCache helper had no other user and is removed.
MaxBitmapImageCacheCount no longer bounds anything; the next commit
marks it obsolete.
A shared decode is now released once nothing displays it, so an image
shown again past the delay is decoded again, and one downloaded over
http or https was downloaded again for that. WinUI keeps an image's
encoded data in its ImageCache only while a BitmapImage with that source
holds it; a new request downloads again through the system HTTP stack,
whose cache follows the caching headers (MUX Reference:
ImageCache::Download, ImageCache.cpp). Uno's HttpClient has no such
cache on desktop.

The bitmap cache now keeps the encoded bytes of downloaded images in
memory. The least recently used go first past the new
MaxBitmapImageCacheBytes (64 MB on desktop, 32 MB on Android, iOS, tvOS
and WebAssembly), and bytes unused for 5 minutes are dropped when the
cache is next used, as the replaced LRU trimmed its entries. For photos,
encoded bytes are about a tenth of the decoded size, so this is far less
memory than the up to 100 decodes the LRU kept. Lowering the setting
drops what no longer fits, and turning EnableBitmapImageCache off drops
it all.

Only bytes that decoded are kept, from a successful response that was
not redirected and has no Cache-Control no-store, no-cache or max-age=0,
nor Pragma: no-cache without Cache-Control. No other freshness rule is
handled. The browser HttpClient on WebAssembly does not expose a
redirect, so there a redirected response is judged by its own headers.
App assets, app data and local files are read again instead, which is
cheap. IgnoreImageCache bypasses the cache; WinUI revalidates such a
request with the server instead.

MaxBitmapImageCacheCount counted cached decodes, which no longer exist.
A count of encoded entries under the same name would mislead, so it is
marked obsolete and has no effect.
The bitmap cache now shares decodes only while something displays them
and keeps the encoded bytes of downloads, bounded by
MaxBitmapImageCacheBytes and dropped after 5 minutes unused. The feature
flags page says so, lists the responses that are not kept, notes that
MaxBitmapImageCacheCount is obsolete, and no longer says that shared
images are left out of the undisplayed-image limit.
Shared decodes are kept while something draws them or was given them to
draw: while either of two sharing sources shows one, and for a moved
Image, an Image a re-entrant change skipped, one that comes back while
the replacement downloads, a drawn brush, an Image that left between
delivery and measure, and a collapsed Image. Once nothing does, they are
released on the same list and limit as owned decodes, and opened again
from one new decode.

An open superseded while taking its pixel size, or whose ImageOpened
handler throws, gives up its shared result, as does a shared load whose
requesters were all cancelled, a brush given another ImageSource, and a
source an ImageOpened handler switched away from. A source collected
while subscribed keeps no decode in the cache, alone or shared with a
live one, and does not hide the live one's use.

A released download is decoded again from its encoded bytes, unless the
response was unsuccessful, redirected (desktop), or said no-store,
no-cache or max-age=0. The encoded cache drops the least recently used
image once full, bytes unused past the expiry, and what a lowered bound
or a disabled cache no longer allows. On WebAssembly, where a test
cannot listen, a stand-in for fetch serves the image and counts its
requests.

Two tests from unoplatform#25124 assumed a shared decode is never released: the
within-delay one now waits with a long delay instead of a limit of 0,
and the cache-off one also checks that the shared decode goes, on a
decode size of its own. The failed-source test also checks the release
with the cache on.
A download decoded on a background thread could pass the
EnableBitmapImageCache check in EncodedImageCache.Add just before the UI
thread turned the cache off and cleared it, then insert its bytes into
the disabled cache. They stayed in memory while the cache was off, and
could be reused once it was turned back on.

The check now runs under the cache's lock, so an insertion either
precedes the clear or sees the cache off.
A shared decode held its sources weakly and dropped a collected one only
when its uses next changed. When the last source using it was collected
after the others had let it go, nothing changed them again. A source
still alive with nothing displaying it, such as one a view model keeps
after its Image left, then kept the decoded pixels, which neither waited
for their release nor counted against MaxUndisplayedImageBytes.

Each source now has one finalizable object that only it holds, which the
decodes it uses know it by. Once the source is collected, the finalizer
queues the uses it still had and posts once to the UI thread, which ends
them as the source would have, so the decode waits for its release like
any other. The finalizer changes no other state, and a failed post
leaves the uses queued for the next one. A use refers to its decode
weakly, so a decode nothing else reaches is collected with its frames.

A collected source's use behind a live one also ends now, where it was
kept until the uses before it ended.
The encoded cache only trimmed when it was next used, so an idle app
kept up to its whole bound, and the 5-minute expiry never ran. A timer
now drops bytes once they expire, and a gen 2 callback drops them
sooner under memory pressure, as the LRUCache it replaced did.
A WinUI BitmapImage holds its ImageCache, and so the encoded data, for
as long as its source stays, whatever the caching headers said. A
BitmapImage whose decode was released downloaded again unless the
shared cache still had the bytes, and failed offline after it had
opened. It now keeps the bytes it downloaded until its source changes,
and decodes them again.
Without a max-age, an Expires date that is past or invalid, such as the
common "0", means the response is already stale.
When the dispatcher refuses the completion, the open is stopped and a
result already at hand is completed as superseded, so its use of a
shared decode ends. Also describes why a collected source can still
count as a user now that brushes subscribe through a lease.
Covers the bytes a BitmapImage keeps for itself, the Expires rule and
when the cache drops its bytes.
A BitmapImage now decodes its own bytes again, so the encoded-cache
tests show a new BitmapImage instead, and the cached rows of the
auto-sized Image test turn the cache off before the source opens again,
so it still downloads. Adds rows for Expires, and tests for the bytes a
source keeps, the expiry while the cache is idle and the memory-pressure
trim.
Assigning an equal Uri reloads the source, as Uri isn't compared by
value: an app refreshing a snapshot that way got the old bytes back.
And a source that joined a shared load another source started lost its
bytes when that download failed, then failed after it had opened; it
now decodes its own bytes instead, and a failure no longer drops them.
A response with a short max-age or Expires stayed reusable for as long
as the cache kept it, and a max-age its Age had used up was reused. The
cache now keeps when a response stops being fresh (max-age, else
Expires less Date, less Age in both cases), and its bytes are not used
once stale. A Cache-Control that doesn't parse makes a response not
reusable, as it may say no-store.

Also: the gen 2 callback trims only above low pressure, which the timer
covers, so tests need no switch for it; and the timer is created
without capturing its first caller's execution context.
Kept bytes are the whole file whatever the decode size, the memory
pressure trim needs MemoryManager (not on iOS or tvOS), and turning the
cache off doesn't drop the bytes a source already kept.
Adds tests for an equal UriSource reassigned, a joined shared load that
fails, and a response no longer fresh, with an Age row. The idle-expiry
and memory-pressure tests get timings that leave room for slow runs and
for a real collection at high pressure.
@mikernet
mikernet force-pushed the dev/mikernet/bitmap-cache-release-25128 branch from 6cf79db to 9ae8529 Compare November 12, 2026 23:54
@mikernet

Copy link
Copy Markdown
Contributor Author

I rebased onto master, so the diff is now this PR's own change, and went through your points:

  1. Memory-pressure trimming. The encoded cache now registers the same gen 2 callback the deleted LRUCache did (DefaultArrayPoolPlatformProvider.RegisterTrimCallback, behind WinRTFeatureConfiguration.ArrayPool.EnableAutomaticMemoryManagement), and above low pressure it drops bytes sooner the higher the pressure, in the LRUCache's proportions (3, 1 and 0.5 minutes of 5). The callback alone wouldn't help an idle app, which may not collect at all, so a timer drops bytes once the 5-minute expiry passes, whether the cache is used or not (ad534d7, 10f49ee). As with the LRUCache, the pressure part needs MemoryManager, which iOS and tvOS don't have. A test covers the timer and the pressure levels, not the gen 2 registration itself.

  2. Re-display. I checked it in the sources: CImageSource holds m_imageCache from ReloadImage until ResetForSourceChange(keepEncodedData: false), and nothing releases ImageCache::m_encodedImageData before then, so a WinUI BitmapImage keeps its encoded bytes for as long as it keeps its source, whatever the headers said. I matched that rather than state a difference: a BitmapImage now keeps the bytes it downloaded over http or https until its source changes, and decodes them again after a release, so a no-store response, evicted bytes or IgnoreImageCache no longer download again, and a released image comes back offline (50d0254). If it joins a shared load another BitmapImage started and that download fails, it decodes its own bytes instead; assigning an equal Uri again drops them, as that reloads the source (10f8532). The cache still decides only what other sources and decode sizes reuse.
    I didn't add a budget for these bytes. WinUI has none, and a budget would bring back ImageFailed after ImageOpened for whatever falls outside it. They are the whole downloaded file: without a decode size, usually a fraction of the full-size decode the same source used to hold forever; with one, they can be larger than the decode, as on WinUI. The docs say so, and mention WebAssembly's memory cap for apps that keep many large BitmapImages alive. A BitmapImage that opens while EnableBitmapImageCache is off keeps none; bytes already kept stay until each source opens again or changes.
    One difference is left: another BitmapImage of the same URI gets those bytes only through the cache, so if the response was not reusable it downloads again, where WinUI shares the live ImageCache. The description says so.

  3. Rebase. Complete now does both: master's KeepWeakCallbacksWhileOpening handling around OnOpened, inside the try whose finally ends the open's use of a shared result. I updated the comments that said a brush keeps its subscription for its lifetime, in SharedImageDecode (c74c86d) and in the collected-source test (34f83de). As you say, the two cover different cases: the lease ends a subscription whose source outlives the brush, and SharedImageDecode.User ends the uses of a source collected with the brush, whose subscription nothing ended. The three collection tests pass on the rebased code on desktop and WebAssembly, as do fix(imaging): Let a long-lived source drop the brushes it painted #25633's brush tests.
    On feature-flags.md and FeatureConfiguration.cs: fix(skia): Share a decoded image's pixels with its texture #25548 rewrites the sentence about what counts toward MaxUndisplayedImageBytes on top of master's wording, which this PR also changes ("an image shared through the bitmap cache counts once" instead of "not counted"). Whichever merges second keeps fix(skia): Share a decoded image's pixels with its texture #25548's texture wording and ends it with "An image shared through the bitmap cache counts once." I didn't fold fix(skia): Share a decoded image's pixels with its texture #25548's text in here, as it describes texture sharing that only fix(skia): Share a decoded image's pixels with its texture #25548 adds.

  4. Smaller points.

    • Expires: without a max-age, an Expires that doesn't parse (such as 0) or isn't later than the response's Date (or now) makes the response not reusable (fd96c69). Going further in the same direction, the cache now also stops using bytes once the response's lifetime passes (max-age, else Expires less Date, less Age in both cases), rather than for as long as it keeps them (10f49ee). Rows cover Expires: 0, past and future dates, an Age that used up the max-age or the Expires lifetime, and a short max-age. A Cache-Control that doesn't parse makes a response not reusable, as it may say no-store.
    • TryEnqueue: when it returns false, Open now stops the open, so a result still to come gives up its use in TryOpenSourceAsync, and completes a result already at hand as superseded, which releases it or ends its use (c74c86d). Uno's DispatcherQueue.TryEnqueue currently always returns true, so this is untested.

Docs: 526e776, e81d2fe. Tests: 34f83de, 9ae8529. The Given_Image test of an auto-sized Image shown while its source opens again now turns the cache off before the cached rows reopen, as the source would otherwise decode its kept bytes without a download to hold.

Image classes on the rebased code: Skia desktop 185 passed, 1 failed, 5 skipped, 1 inconclusive; WebAssembly 141 passed, 1 failed, 41 skipped (desktop-only), 2 inconclusive. The failure on both, When_WriteableBitmap_Assigned_With_Data_Present, also fails on master on this machine (125% scaling). After the last freshness changes, Given_Image_Release alone: desktop 102 passed, WebAssembly 66 passed and 32 skipped (desktop-only). Each new test or row fails with its product change reverted.

mikernet added a commit to Singulink/uno that referenced this pull request Nov 13, 2026
…e (port)

Port of unoplatform#25559 commits ad534d7..9ae8529 (ad534d7,
50d0254, fd96c69, c74c86d, 526e776, 34f83de, 10f8532,
10f49ee, e81d2fe, 9ae8529), on top of this build's earlier port
of that PR.

- A BitmapImage keeps the encoded bytes it downloaded for as long as its
  UriSource stays, as WinUI's ImageCache does, so decoding it again needs
  no network. They are dropped when the source is set again (also to an
  equal Uri) or unloaded, replaced only by a result with data, and a
  source whose joined shared load fails decodes its own kept bytes.
- The encoded image cache reuses bytes only while fresh (max-age, else
  Expires less Date, less Age; no-store, no-cache, Pragma and an
  unparseable Cache-Control are not reusable), drops bytes idle for 5
  minutes on a timer, and trims above Low memory pressure from a gen 2
  callback.
- An open whose completion can't be enqueued gives up a result already
  at hand instead of leaving its shared-decode use open.

Adapted to this build: no browser Canvas API flag in the decode path,
and BitmapImage's open lives in BitmapImage.skia.cs.
mikernet added a commit to Singulink/uno that referenced this pull request Nov 14, 2026
Takes Uno's servicing/6.8 from 6951c2c up to 5254ace (42 pull
requests). Conflicts, by area:

IME and TextBox (unoplatform#25984, backport of unoplatform#25804; unoplatform#25952, backport of unoplatform#25619;
unoplatform#25976, backport of unoplatform#25818)
- Takes upstream's composition design (unoplatform#25804): the browser's input is
  the source of truth for the text, compositions included. This drops
  the composition parts of the unoplatform#24502 port (90746b8): its
  TypeScript composition handling, the StartIndex/TextChangePending
  event arguments, the IsReadOnly forwarding to the hidden input, and
  its Given_TextBox.BrowserSoftKeyboard tests.
- Keeps the non-IME browser fixes on top of it, in the form their
  master pull requests have on unoplatform#25804: Enter raised once per press and
  tracked by key code (unoplatform#25174, with Given_TextBox.BrowserEnterKey from
  master in place of the Enter tests that lived in the dropped file),
  the iOS refocus on tap (unoplatform#25185), pastes without text and isIOS from
  BrowserInputHelper.
- Moves the unoplatform#26005 MaxLength fixes onto unoplatform#25804's native input path, as
  master has them: a refused browser edit keeps the TextBox's selection,
  TextBoxView.UpdateTextFromNative reports whether the edit went in, and
  the IME keeps its range on the applied text (master be85699, which
  d226897 had ported onto the unoplatform#24502 IME). Adds that commit's browser
  and platform-applied IME tests.
- TextBox.pointers.skia.cs: upstream's file (unoplatform#25619 iOS touch editing,
  unoplatform#25818, unoplatform#25804), with the unoplatform#25126 port (the hold acts when it starts,
  a touch right-tap keeps the caret) in the form master's unoplatform#25126 branch
  has it on unoplatform#25619, including focusing before the iOS hold timer acts
  and its iOS not-focusable test.

Pointers (unoplatform#25976, backport of unoplatform#25818)
- Tapped and RightTapped are raised once the PointerReleased bubbled
  (unoplatform#25818), with the unoplatform#25732 port's window coordinates (2-argument
  routed event args, RawPosition for ContextRequested), as master's
  unoplatform#25732 branch has them.
- The release wrapper of unoplatform#25818 keeps the unoplatform#25126 port's
  StopContextMenuTimer at the start of the release.

DependencyObject (unoplatform#26142, backport of unoplatform#26123)
- Takes upstream, then the code of unoplatform#25839 (97a2f17), which replaces
  this branch's unoplatform#25102 ports (800247f, be41da6, fd40bf9,
  7546379). DependencyObjectStore.Binder.cs, .alc.cs,
  Application.Alc.cs, ResourceDictionary.alc.cs and their tests now
  match unoplatform#25839; this drops 800247f's own
  SingleParentNonFE_Parent_Removes_Value_Then_Next_Parent_Starts_Clean
  test, which unoplatform#25839 doesn't have.

Imaging (unoplatform#25597, unoplatform#25915, unoplatform#25911, unoplatform#25897)
- Keeps this branch's side: it has the later forms of these fixes
  (unoplatform#25124, unoplatform#25559, unoplatform#25633 and unoplatform#26370 ports), where upstream has the
  6.8-only unoplatform#25597 design and earlier snapshots. Upstream's shared
  download, IgnoreImageCache and failed-load fixes are covered by
  SharedImageDecode. Adds upstream's When_Source_Outlives_Brushes test.

ToolTip (unoplatform#26098, backport of unoplatform#25137 and unoplatform#26096)
- Keeps this branch's unoplatform#25137 port and adds unoplatform#26096 in master's form: the
  service releases only a ToolTip it opened, and leaves one the app
  opened during the show delay to the app. Keeps both sides' tests.

Flyout (unoplatform#26062, backport of unoplatform#26060)
- This branch had the same commits already; keeps the "Keep
  non-resizable flyouts in window" port and its tests.

Also removes a helper pair in Given_AccessibleTextBox that both sides
added.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/skia ✏️ Categorizes an issue or PR as relevant to Skia kind/documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Skia] A bitmap cache entry evicted by the LRU leaves its shared decoded surface to finalization

6 participants