Visitar URL original
fix(imaging): Hold image source subscribers weakly (6.8) by mikernet · Pull Request #25597 · unoplatform/uno · GitHub
Skip to content

fix(imaging): Hold image source subscribers weakly (6.8) - #25597

Merged
MartinZikmund merged 3 commits into
unoplatform:servicing/6.8from
mikernet:dev/mikernet/68-image-source-weak-subscribers
Nov 2, 2026
Merged

MartinZikmund merged 3 commits into
unoplatform:servicing/6.8from
mikernet:dev/mikernet/68-image-source-weak-subscribers

Conversation

@mikernet

@mikernet mikernet commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

GitHub Issue: closes #25596 (for 6.8)

PR Type:

🐞 Bugfix

What changed? 🚀

This PR targets servicing/6.8 directly. On master, #25124 (open) fixes the Image part of this leak: an Image subscribes to its source only while it's loaded. That PR is too large to backport, so this is a narrow fix for 6.8 that changes nothing about when sources open or decode, or when ImageOpened and ImageFailed are raised.

The leak:

  • The source: ImageSource kept its subscribers in a strong list.
  • Image: the Skia Image also subscribed to an SvgImageSource's SourceLoaded and OpenFailed events.
  • ImageBrush: it did the same with its subscription and with a handler on ImageSource.Invalidated.
  • The effect: all of these were only released when the consumer's source changed. So a source that outlives its consumers, such as an icon in a static field or in app resources, kept every Image and ImageBrush that ever used it alive, along with everything they reference. In a WebAssembly app, static SVG icon sources held 176 handlers this way, and about 30 MB of departed pages behind them.

The fix:

  • Weak subscribers: Image (Skia and native WebAssembly) and ImageBrush (crossruntime and native WebAssembly, through Shape) subscribe through a new SubscribeWeakly, which ImageSource holds only weakly. The disposable it returns keeps the callback alive for as long as the consumer holds it, and each of them stores it.
  • Strong by default: Subscribe still keeps its subscribers alive until they dispose, so a caller that doesn't hold the disposable keeps working. BitmapSource.SetSourceAsync and SvgImageSource.SetSourceAsync hold theirs only in an async local, so they use it.
  • Opens in progress: an open keeps the subscribers it starts with alive until it completes, as one may be waiting for it with nothing else referencing it, such as a brush whose ImageOpened an app awaits. No test covers this case.
  • Forwarders: the SVG events (Image.skia.cs) and Invalidated (ImageBrush.cs) go through small forwarders that hold the consumer weakly. Each forwarder unsubscribes itself the next time the source raises that event after its consumer is gone.

Not covered here:

Tests

These are Skia runtime tests.

  • Given_Image.When_Source_Outlives_Images (bitmap and SVG): two Images sharing a source the test keeps alive are collected once removed from the tree.
  • Given_ImageBrush.When_Source_Outlives_Brushes: two Borders painted by ImageBrushes over a shared source are collected once removed.
  • Given_Image.When_Source_Updated_After_GC (bitmap and SVG): an un-awaited SetSourceAsync completes despite a collection, and a live Image still receives its source's next update after several collections. This guards the weak references against dropping live subscribers.

On vanilla 6.8, the three collection tests fail and the two update tests pass. With this PR, all five pass, five times in a row. The update tests time out if Subscribe holds its subscribers only weakly and the open doesn't keep them alive.

Related classes with this PR: Given_Image, Given_ImageBrush, Given_BitmapSource, Given_ImageSource, Given_WriteableBitmap, Given_RenderTargetBitmap and Given_BitmapIcon.

  • Passed: 77 of 79.
  • When_WriteableBitmap_Assigned_With_Data_Present: fails with the same message on vanilla 6.8 on this machine.
  • When_Render_Border_GetPixelsAsync: inconclusive, as it is on vanilla.

Uno.UI builds cleanly for Skia, native WebAssembly and Reference, with analyzers on.

PR Checklist ✅

Show a source kept alive by the test, like an icon in a static field or
in the application resources, in two Images and in two Borders painted
by ImageBrushes, then remove them and check that they are collected.
This is done for a BitmapImage and an SvgImageSource.

Also check that an Image keeps receiving the updates of its source
after collections, including from a SetSourceAsync that nothing but the
source references while it runs.

The collection tests fail until the next commit.
An ImageSource kept its subscribers in a strong list, and the Skia
Image subscribed to the SourceLoaded and OpenFailed events of an
SvgImageSource. ImageBrush did the same with its subscription and with
a handler on ImageSource.Invalidated. All of these were only released
when the consumer's source changed, so a source that outlives its
consumers, such as an icon in a static field or in the application
resources, kept every Image and ImageBrush that ever used it alive,
along with everything they reference.

Hold the subscribers weakly instead: the disposable returned by
Subscribe keeps the callback alive for as long as the consumer holds
it, and an open in progress keeps the current subscribers alive until
it completes, as a SetSourceAsync awaiting it may be referenced by
nothing else. The SVG events and Invalidated are forwarded through
handlers that hold the consumer weakly and unsubscribe once it is gone.
When sources open and when ImageOpened and ImageFailed are raised is
unchanged.

This is a narrow servicing fix: on master, Image subscribing only while
loaded (unoplatform#25124) fixes this as part of a much larger change. A source
used by a single element still references it as its inheritance
context parent until another element uses it, which is addressed
separately (unoplatform#25102) and not covered here.

Fixes unoplatform#25596
@github-actions github-actions Bot added the area/skia ✏️ Categorizes an issue or PR as relevant to Skia label Oct 20, 2026
@agneszitte

Copy link
Copy Markdown
Member

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your WebAssembly Sample App stage site is ready! Visit it here:

@nventive-devops

Copy link
Copy Markdown
Contributor

The build 240475 did not find any UI Test snapshots differences.

@unodevops

Copy link
Copy Markdown
Contributor

⚠️⚠️ The build 240475 has failed on Uno.UI - CI.

@Xiaoy312 Xiaoy312 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.

Approving. This is a nice, narrow fix for a servicing branch. A few notes, none blocking:

What I checked

  • Since Subscribe now requires callers to hold the returned disposable, I went through every call site. All of them keep it: Image.skia / Image.wasm (_sourceDisposable), ImageBrush.crossruntime (_sourceSubscription), and using var in BitmapSource.ForceLoad / SvgImageSource.SetSourceAsync.
  • Native WASM isn't covered by the new tests, so I traced it by hand: ImageBrush.ToSvgElement returns subscriptionDisposable inside its CompositeDisposable, which flows through BrushDef.InnerSubscription → TryAppendBrushDef → Shape._fillBrushSubscription. Native WASM Border and text read ImageDataCache, which the brush's own subscription fills.
  • SetSourceAsync subscribes before calling InvalidateSource, so the keep-alive in Open() covers it.
  • CI: the new tests passed on Android, iOS, Windows, Linux, Linux Framebuffer and WASM Skia. The macOS shard died with a segfault (exit 139) in When_WebMessageReceived. That's the known WebView crash already red on servicing/6.8, unrelated to this PR.

Nits

  1. Leftover forwarders. Each departed Image / ImageBrush leaves one small forwarder subscribed on a long-lived source. It no longer holds the consumer, so this is a big improvement. But forwarders are only pruned when their event fires: Invalidated only fires on a UriSource change and SourceLoaded only on a reload. For a static icon they accumulate indefinitely, and each += copies the invocation list. Fine for 6.8, but probably worth a mention in the description.
  2. The contract lives only in the doc comment. Every caller holds the disposable today, but a future caller that drops it would fail nondeterministically after a GC.
  3. Minor allocations. Subscribe and InvalidateSource allocate a list through GetSubscribers() just to read .Count. A count-only prune would avoid that.

Questions

  • Single-consumer case. As you noted, a source used by one element at a time still retains that element through _associatedParent, and that's probably the common case (an icon on a page you navigate away from). Is a backport of #25102, or a narrow slice of it, planned for 6.8?
  • Native WASM in the tests. If the reporting app was native WASM rather than Skia, consider adding native WASM to the tests' PlatformCondition, if 6.8 CI still runs a native WASM shard.

@mikernet

mikernet commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

@Xiaoy312 I assumed you wouldn't want the full #25124 backport for the 6.8 servicing release but I probably should have asked first. I did backport it into our own custom 6.8 build, so the option is there if you want a PR for it (I have backports for every PR I submitted on master that apply to 6.8 in our custom build, so depends how far you want to go on 6.8).

On master, the Image part of this leak is fixed by #25124, where an Image subscribes to its source only while it's loaded. That PR is too large to backport, so this is a narrow fix for 6.8 that changes nothing about when sources open or decode, or when ImageOpened and ImageFailed are raised.

Subscribe holds its subscribers again, so a caller that doesn't keep
the returned disposable keeps being notified. Image and ImageBrush,
which a long-lived source must not keep alive, subscribe through the
new SubscribeWeakly instead. BitmapSource.SetSourceAsync and
SvgImageSource.SetSourceAsync hold their subscription only in an async
local, so they now rely on Subscribe holding it rather than on the open
keeping its subscribers alive.

Subscribe and InvalidateSource only need the number of subscribers, so
they now count them without building a list.

Refs unoplatform#25596
@mikernet

Copy link
Copy Markdown
Contributor Author

@Xiaoy312 Thanks for the review.

Single-consumer case. A backport of #25102 isn't planned yet, because #25102 is still waiting for review on master. Our custom 6.8 build has carried a port of it since Oct 8 (Singulink/uno@800247fb8a, plus be41da640b for its review round). That port touches 13 product files and brings the one visible change #25102 describes: replacing a bound shared value on an element and putting it back resets its bound properties to their defaults in between. So I'd rather not send the whole port to servicing before master has it.

The part that matters here is small: the weak _associatedParent in DependencyObjectStore.Binder.cs, and the ALC sweep that reads it in DependencyObjectStore.alc.cs. That's two files, +59 −22, about half of it comments. A collected first parent still counts as a parent, as the live one did before, so inheritance behaves the same and the element just becomes collectable. The one difference is that the ALC sweep also frees the slot of a parent that was collected. On 6.8 a source stops being an inheritance context for good once a second element uses it, so without the slice, a source keeps at most one departed element, until another element uses it.

I tried that slice on top of this PR with one Image (bitmap and SVG) and one ImageBrush on a Border. All three are kept alive without the slice and collected with it. With the slice on Skia desktop, I also ran Given_UIElement, Given_BindingMemoryLeak, Given_CollectibleAlcAssociations, Given_ResourceDictionary_DataContext, Given_DependencyObjectCollection, Given_FrameworkElement_And_Leak, Given_Image and Given_ImageBrush. The only failures were:

  • 10 Given_UIElement layout tests, off by sub-pixel rounding (this machine runs at 125% scaling).
  • CommandBarFlyout_Leak, which fails the same way on stock 6.8 here.

I can open either against servicing/6.8: that slice now, or the full port once #25102 merges. Which would you prefer?

Native WASM. The reporting app uses the Skia renderer on WebAssembly, which the WASM Skia shard covers. On 6.8, native WASM runtime tests only run on branch builds, never on PRs, so adding NativeWasm here would first run after the merge. I haven't run these tests on native WASM, so I'd rather not add them untested.

Nits. Addressed in 3cc4c1e205:

  1. Forwarders: they're now described under "Not covered here". The fixes proposed for master avoid them: fix(imaging): Release a decoded image when nothing displays it #25124 has an Image listen only while it's loaded, and fix(imaging): Let a long-lived source drop the brushes it painted #25633 registers Invalidated through WeakEventHelper. WeakEventHelper keeps the first subscriber of each event alive until fix(weakevents): Don't keep the first subscriber of a weak event alive #25591 merges (WeakEventHelper keeps the first subscriber of each weak event alive for as long as the event source lives #25590), and that fix isn't on 6.8, which is why this PR uses forwarders.
  2. The contract: Subscribe keeps its subscribers alive again, and weak subscriptions are opt-in through SubscribeWeakly, as in fix(imaging): Let a long-lived source drop the brushes it painted #25633. Only Image and ImageBrush use it, so a caller that drops the disposable keeps working.
    • SetSourceAsync uses Subscribe, so it no longer depends on the open's keep-alive. I checked that the update tests still time out if neither holds its subscription.
    • The keep-alive stays for consumers an app may wait on with nothing else referencing them, such as a brush whose ImageOpened it awaits. No test covers that case.
  3. Allocations: Subscribe and InvalidateSource now prune and count without building a list.

The related classes give the same results as before, and the five new tests pass five times in a row. Uno.UI builds cleanly for Skia, native WebAssembly and Reference, with analyzers on.

@github-actions github-actions Bot added the platform/wasm 🌐 Categorizes an issue or PR as relevant to the WebAssembly platform label Oct 22, 2026
@Xiaoy312

Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your WebAssembly Sample App stage site is ready! Visit it here:

@nventive-devops

Copy link
Copy Markdown
Contributor

The build 242560 did not find any UI Test snapshots differences.

@mergify

mergify Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

This pull request does not currently match the merge queue conditions, so it cannot be queued from here. The box comes back if it matches again.

@MartinZikmund
MartinZikmund merged commit 9851f2e into unoplatform:servicing/6.8 Nov 2, 2026
42 checks passed
@agneszitte

Copy link
Copy Markdown
Member

@Mergifyio backport release/stable/6.7

@mergify

mergify Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

backport release/stable/6.7

✅ Backports have been created

Details

agneszitte added a commit that referenced this pull request Nov 3, 2026
…6.7/pr-25597

fix(imaging): Hold image source subscribers weakly (6.8) (backport #25597)
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 platform/wasm 🌐 Categorizes an issue or PR as relevant to the WebAssembly platform

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants