Skip to content

Fix ServerCompositionDrawingSurface orphaning GPU snapshots when an update is processed after Dispose - #21876

Merged
kekekeks merged 2 commits into
AvaloniaUI:mainfrom
ddunham121:fix/drawing-surface-dispose-guard
Aug 26, 2026
Merged

kekekeks merged 2 commits into
AvaloniaUI:mainfrom
ddunham121:fix/drawing-surface-dispose-guard

Conversation

@ddunham121

Copy link
Copy Markdown
Contributor

What does the pull request do?

Fixes ServerCompositionDrawingSurface orphaning GPU-backed snapshots when an update job is processed after the surface's disposal, which is the root cause of the native AV inside sk_canvas_flush reported in #21865.

What is the root cause?

A commit batch is serialized (and therefore processed on the render thread) with the dispose list before queued server jobs. So the perfectly legal user-code order:

surface.UpdateAsync(image); // e.g. a swapchain present
surface.Dispose();

executes on the render thread as Dispose() → UpdateWithKeyedMutex/…(). Update() has no disposed state, so it stores a fresh snapshot ref into the already-disposed surface. Nothing ever disposes that ref again. The GPU-backed ImmutableBitmap inside it is then released by the RefCountable.Ref<T> critical finalizer, whose dispose callback performs GPU work (EglContext.MakeCurrent() + native SKImage dispose) on the .NET finalizer thread, racing the compositor render loop mid-Blit/sk_canvas_flush.

This is not theoretical: in a production crash dump attached to #21865 (details in a follow-up comment there), the finalizer thread was captured exactly in RefCountable.Ref<T>.Finalize() → ImmutableBitmap.Dispose() → GlSkiaImportedImage.<TakeSnapshot>b__0 → EglContext.MakeCurrent() while the render thread died in SurfaceRenderTarget.Blit → sk_canvas_flush.

What is the fix?

ServerCompositionDrawingSurface gets a _disposed flag. An Update processed after Dispose now disposes the fresh snapshot immediately on the render thread — where the GPU context is current — instead of orphaning it to the finalizer.

Per the contributing guide, the first commit adds the failing test (fails on current main: the snapshot is orphaned undisposed), the second commit fixes it. The test exercises the real commit/batch pipeline via CompositorTestServices with a fake external-objects feature, so the dispose-before-jobs ordering is demonstrated end-to-end, headlessly.

Field validation

We ship a desktop app whose users hit this crash across a wide range of GPUs (RTX 4070 12GB through 128MB-VRAM Intel iGPUs — dumps for two distinct libSkiaSharp fault sites are attached to #21865). We have been running this exact fix (backported to 11.3.18) in our production build since 2026-07-28.

Checklist

…spose orphans the GPU snapshot

A commit batch is processed on the render thread in serialization order: the
dispose list is written before queued server jobs. The legal user-code order
`surface.UpdateAsync(image); surface.Dispose();` therefore executes on the
render thread as Dispose() -> UpdateWithAutomaticSync(), and the update stores
a fresh snapshot ref into the already-disposed ServerCompositionDrawingSurface.
Nothing disposes that ref afterwards, so the GPU-backed snapshot is cleaned up
by the RefCountable.Ref<T> critical finalizer, which performs GPU work on the
finalizer thread and races the compositor render loop (AvaloniaUI#21865).
…pdate is processed after Dispose

Add a disposed flag: an Update processed after Dispose now disposes the fresh
snapshot immediately on the render thread (where the GPU context is current)
instead of storing it into the disposed surface where nothing will ever
release it. This removes the finalizer-thread GPU cleanup path that races the
compositor render loop and crashes in sk_canvas_flush (AvaloniaUI#21865).
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0067865-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@cla-avalonia

cla-avalonia commented Jul 28, 2026 •

Copy link
Copy Markdown
Collaborator
  • All contributors have signed the CLA.

@ddunham121

Copy link
Copy Markdown
Contributor Author

@cla-avalonia agree

@MrJul

MrJul commented Jul 30, 2026

Copy link
Copy Markdown
Member

While the fix looks solid and protects against the update occurring after disposal, I'd argue that the following claim isn't valid:

So the perfectly legal user-code order:

surface.UpdateAsync(image); // e.g. a swapchain present
surface.Dispose();

The UpdateAsync call should have been awaited.
That said, I'm fully aware that Avalonia is using this pattern, e.g. here (LastPresent is the task containing the UpdateAsync call):

// No need to wait for import / LastPresent since calls are serialized on the compositor side anyway

This comment was true when it was written, but it no longer holds now that Dispose calls are processed inline.

I'm inclined to accept this PR, as the hardening is good, but we should fix the GL swapchains. The Vulkan one is correctly awaiting:

if (LastPresent != null)
await LastPresent;

cc @kekekeks for additional thoughts.

@kekekeks

Copy link
Copy Markdown
Member

I think it's a valid change just for defensiveness. Dispose should generally avoid breaking the application. E. g. when you dispose Socket with pending SendAsync, the it doesn't suddenly leak memory, so Dispose effectively cancelling pending operations is an established pattern in .NET

@kekekeks
kekekeks added this pull request to the merge queue Aug 26, 2026
Merged via the queue into AvaloniaUI:main with commit ea2dc8f Aug 26, 2026
10 checks passed
MrJul pushed a commit to MrJul/Avalonia that referenced this pull request Sep 2, 2026
…pdate is processed after Dispose (AvaloniaUI#21876)

* Add failing test: CompositionDrawingSurface update processed after Dispose orphans the GPU snapshot

A commit batch is processed on the render thread in serialization order: the
dispose list is written before queued server jobs. The legal user-code order
`surface.UpdateAsync(image); surface.Dispose();` therefore executes on the
render thread as Dispose() -> UpdateWithAutomaticSync(), and the update stores
a fresh snapshot ref into the already-disposed ServerCompositionDrawingSurface.
Nothing disposes that ref afterwards, so the GPU-backed snapshot is cleaned up
by the RefCountable.Ref<T> critical finalizer, which performs GPU work on the
finalizer thread and races the compositor render loop (AvaloniaUI#21865).

* Fix ServerCompositionDrawingSurface orphaning GPU snapshots when an update is processed after Dispose

Add a disposed flag: an Update processed after Dispose now disposes the fresh
snapshot immediately on the render thread (where the GPU context is current)
instead of storing it into the disposed surface where nothing will ever
release it. This removes the finalizer-thread GPU cleanup path that races the
compositor render loop and crashes in sk_canvas_flush (AvaloniaUI#21865).
@MrJul MrJul added backported-12.1.x and removed backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch labels Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants