Skip to content

Raise TopLevel.Closed from managed Dispose() paths - #22045

Merged
MrJul merged 1 commit into
mainfrom
fixes/toplevel-close-not-called
Aug 24, 2026
Merged

MrJul merged 1 commit into
mainfrom
fixes/toplevel-close-not-called

Conversation

@kekekeks

Copy link
Copy Markdown
Member

This PR addresses 2 problems:

  1. We've sometimes do not to call TopLevel.Closed when toplevel implementation is getting destroyed (most notably in various embedding scenarios). This was causing various issues with complex scenarios on webassembly.

The PR re-routes Closed to EnsureClosed that has a double-call guard (can't have it in HandleClosed since it's virtual) and calls HandleClosed from various disposable toplevels. While it would technically be more correct to fix implementations, but I'd still make multiple calls HandleClosed to be safe by having that check.

  1. Android still had old timer hack that paused render timer when there were no active views, which was an incorrect behavior - UI thread is allowed to submit render thread jobs whenever it wants.

TopLevel teardown (HandleClosed) was only ever triggered by the backend
invoking ITopLevelImpl.Closed. Browser, iOS, Android, macOS and the offscreen
(designer previewer) impls never raise it from Dispose(), so
EmbeddableControlRoot.Closed was never raised there and StopRendering() was
never reached - the top level stayed registered in MediaContext forever.

Make teardown idempotent behind EnsureClosed() and call it from the managed
Dispose() paths (EmbeddableControlRoot, OffscreenTopLevel, PopupRoot). The
guard lives in a new non-virtual entry point because WindowBase and Window
override HandleClosed and run side effects before calling base.

Also drop ChoreographerTimer's view-visibility gate on Android. It predates
the render timer rewrite and vetoed ticks that DefaultRenderLoop had explicitly
asked for, so the synchronous compositor round-trip in HandleClosed
(Renderer.Dispose -> MediaContext.SyncDisposeCompositionTarget) could never
complete once the view had unsubscribed - deadlocking every activity destroy.
DefaultRenderLoop already owns the sleep/wake state machine, driven by
StartRendering/StopRendering, which makes the extra gate redundant.

Verified at runtime on X11, Wayland, Win32, macOS, Headless, Browser (WASM),
Android and iOS: Closed fires exactly once per teardown, MediaContext returns
to baseline, and platform-initiated closes still fire exactly once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@avaloniaui-bot

Copy link
Copy Markdown

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

@MrJul MrJul added bug backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch labels Aug 24, 2026
@MrJul MrJul self-assigned this Aug 24, 2026

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

I've left a comment about OffscreenTopLevel but it predates this PR.
LGTM!

public void Dispose()
{
PlatformImpl?.Dispose();
EnsureClosed();

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.

OffscreenTopLevel is internal, unused and doesn't even implement IDisposable. Should we just delete this class?

@MrJul
MrJul added this pull request to the merge queue Aug 24, 2026
Merged via the queue into main with commit 7f45605 Aug 24, 2026
11 checks passed
@MrJul
MrJul deleted the fixes/toplevel-close-not-called branch August 24, 2026 16:29
MrJul pushed a commit to MrJul/Avalonia that referenced this pull request Sep 2, 2026
TopLevel teardown (HandleClosed) was only ever triggered by the backend
invoking ITopLevelImpl.Closed. Browser, iOS, Android, macOS and the offscreen
(designer previewer) impls never raise it from Dispose(), so
EmbeddableControlRoot.Closed was never raised there and StopRendering() was
never reached - the top level stayed registered in MediaContext forever.

Make teardown idempotent behind EnsureClosed() and call it from the managed
Dispose() paths (EmbeddableControlRoot, OffscreenTopLevel, PopupRoot). The
guard lives in a new non-virtual entry point because WindowBase and Window
override HandleClosed and run side effects before calling base.

Also drop ChoreographerTimer's view-visibility gate on Android. It predates
the render timer rewrite and vetoed ticks that DefaultRenderLoop had explicitly
asked for, so the synchronous compositor round-trip in HandleClosed
(Renderer.Dispose -> MediaContext.SyncDisposeCompositionTarget) could never
complete once the view had unsubscribed - deadlocking every activity destroy.
DefaultRenderLoop already owns the sleep/wake state machine, driven by
StartRendering/StopRendering, which makes the extra gate redundant.

Verified at runtime on X11, Wayland, Win32, macOS, Headless, Browser (WASM),
Android and iOS: Closed fires exactly once per teardown, MediaContext returns
to baseline, and platform-initiated closes still fire exactly once.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@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.

3 participants