Open Closed

LeptonX ContentToolbar/Breadcrumbs never unsubscribe from PageLayout — async void handler terminates the host process (ObjectDisposedException #10861


User avatar
0
RedEnzian created

i ran into a bug inside our application and after multiple debug sessions, me and claude found the problem. We also could reproduce the problem in a blank ABP project created with ABP Studio.

Summary

ContentToolbar and Breadcrumbs subscribe to the scoped PageLayout with anonymous async void handlers and never unsubscribe (neither component implements IDisposable).

On a Blazor Web App using InteractiveAuto, a layout change can reach a component the renderer has already discarded. Its render then resolves child components from a disposed Autofac scope, and because the handler is async void the resulting ObjectDisposedException is rethrown on a thread-pool thread — which terminates the whole host process, not just the circuit.

This is not a failed render. dotnet run exits; in production the container dies and restarts.

Severity

High. Unhandled process termination triggered by ordinary navigation, with no application code at fault and no way for the application to catch it.

Environment

| | | |---|---| | ABP Commercial | 10.6.0 (also reproduced on 10.5.0) | | Volo.Abp.AspNetCore.Components.Server.LeptonXTheme | 5.6.0 (also 5.5.0) | | UI | Blazor Web App, @rendermode="InteractiveAuto", LeptonX side-menu layout | | DI | Autofac (Volo.Abp.Autofac 10.6.0) | | Runtime | .NET SDK 10.0.400, Windows |

Reproduction — 100% reproducible (3/3 attempts)

  1. Create a new ABP solution: Blazor Web App UI, LeptonX theme, EF Core. (I used the stock template unmodified apart from step 2.)
  2. Add the page below to the .Blazor.Client project as Pages/Repro.razor.
  3. Run DbMigrator, start the host, log in as admin.
  4. Navigate to /repro.

The page renders, then the host process exits.

@page "/repro"
@using AbpDefaultSolution.Books
@using AbpDefaultSolution.Permissions
@using Microsoft.AspNetCore.Authorization
@using Volo.Abp.Application.Dtos
@using Volo.Abp.AspNetCore.Components.Web.Theming.Layout
@using Volo.Abp.AspNetCore.Components.Web.Theming.PageToolbars
@using Volo.Abp.BlazoriseUI.Components
@inherits AbpDefaultSolutionComponentBase
@inject IBookAppService BookAppService
@inject IAuthorizationService AuthorizationService

<PageHeader Title="Repro" BreadcrumbItems="BreadcrumbItems" Toolbar="Toolbar"></PageHeader>

@code {
    protected List<Volo.Abp.BlazoriseUI.BreadcrumbItem> BreadcrumbItems = [];
    protected PageToolbar Toolbar { get; } = new();

    protected override void OnInitialized()
    {
        BreadcrumbItems.Add(new Volo.Abp.BlazoriseUI.BreadcrumbItem("Repro"));
        Toolbar.AddButton("New", () => Task.CompletedTask, IconName.Add,
            requiredPolicyName: AbpDefaultSolutionPermissions.Books.Create);
    }

    // Several sequential awaits, as ABP Suite generates for a CRUD page.
    protected override async Task OnInitializedAsync()
    {
        await AuthorizationService.IsGrantedAsync(AbpDefaultSolutionPermissions.Books.Default);
        await AuthorizationService.IsGrantedAsync(AbpDefaultSolutionPermissions.Books.Create);
        await AuthorizationService.IsGrantedAsync(AbpDefaultSolutionPermissions.Books.Edit);
        await AuthorizationService.IsGrantedAsync(AbpDefaultSolutionPermissions.Books.Delete);
        await BookAppService.GetListAsync(new PagedAndSortedResultRequestDto());
    }
}

Important: the built-in module pages do not trigger this

I tested the following on the same clean template and all of them survived, including those that render a PageHeader toolbar:

/Identity/Users, /Identity/Roles, /Saas/Tenants, /AuditLogs, /TextTemplates, /LanguageManagement/Languages, /OpenIddict/Applications, /FileManagement

The multi-await OnInitializedAsync in the repro page is what widens the window enough for the InteractiveAuto hand-off to dispose the circuit scope while the theme's handler is still queued. In our production application, every ABP Suite generated CRUD page crashes the host this way.

Actual result

Unhandled exception. System.ObjectDisposedException: Instances cannot be resolved and nested
lifetimes cannot be created from this LifetimeScope as it (or one of its parent scopes) has
already been disposed.
   at Autofac.Core.Lifetime.LifetimeScope.ThrowDisposedException()
   at Autofac.Core.Lifetime.LifetimeScope.ResolveComponent(ResolveRequest& request)
   at Autofac.Extensions.DependencyInjection.AutofacServiceProvider.GetService(Type serviceType)
   at Blazorise.ComponentActivator.CreateInstance(Type componentType)
   at Microsoft.AspNetCore.Components.ComponentFactory.InstantiateComponent(IServiceProvider serviceProvider, Type componentType, IComponentRenderMode callerSpecifiedRenderMode, Nullable`1 parentComponentId)
   at Microsoft.AspNetCore.Components.RenderTree.Renderer.InstantiateChildComponentOnFrame(RenderTreeFrame[] frames, Int32 frameIndex, Int32 parentComponentId)
   at Microsoft.AspNetCore.Components.RenderTree.RenderTreeDiffBuilder.InitializeNewComponentFrame(DiffContext& diffContext, Int32 frameIndex)
   ...
   at Microsoft.AspNetCore.Components.RenderTree.Renderer.ProcessRenderQueue()
   at Microsoft.AspNetCore.Components.ComponentBase.StateHasChanged()
   at Microsoft.AspNetCore.Components.Rendering.RendererSynchronizationContext.&lt;InvokeAsync&gt;g__Execute|8_0(ValueTuple`3 state)
   at Volo.Abp.AspNetCore.Components.Web.LeptonXTheme.Components.ApplicationLayout.Common.ContentToolbar.<obfuscated>(Object s, PropertyChangedEventArgs)
   at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__124_1(Object state)
   at System.Threading.QueueUserWorkItemCallback.Execute()
   at System.Threading.ThreadPoolWorkQueue.Dispatch()
   at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()
   at System.Threading.Thread.StartCallback()

Client side, the corrupted render batch surfaces as:

Error: There was an error applying batch N
SyntaxError: Unexpected number in JSON at position 1 (line 1 column 2)

Expected result

A layout change reaching a disposed component should be a no-op. Under no circumstances should a theme component be able to terminate the host process.

Root cause

Volo.Abp.AspNetCore.Components.Web.LeptonXTheme/Components/ApplicationLayout/Common/ContentToolbar.razor.cs:

protected override Task OnInitializedAsync()
{
    PageLayout.ToolbarItems.CollectionChanged += async (s, e) => await RenderAsync();
    PageLayout.PropertyChanged            += async (s, e) => await InvokeAsync(StateHasChanged);
    return base.OnInitializedAsync();
}

Three problems compound:

  1. The handlers are async void — an exception escapes to the thread pool and kills the process.
  2. They are anonymous, so they could not be unsubscribed even if the component wanted to.
  3. The component has no Dispose, and PageLayout is scoped — so it holds every ContentToolbar instance created in that scope alive, including discarded ones.

Breadcrumbs.razor.cs has the identical defect:

PageLayout.BreadcrumbItems.CollectionChanged += async (s, e) => await InvokeAsync(StateHasChanged);
PageLayout.PropertyChanged                  += async (s, e) => await InvokeAsync(StateHasChanged);

For contrast, SideMenu/Navigation/MainMenuItem.razor.cs does this correctly — named handler, IDisposable, unsubscribes. TopMenu/Navigation/MainMenuItem.razor.cs subscribes in OnParametersSet and never unsubscribes (a leak rather than a crash, since its handler is not async).

Suggested fix

For both ContentToolbar and Breadcrumbs:

  • store the handlers in fields rather than anonymous lambdas;
  • implement IDisposable and unsubscribe there;
  • make the handlers non-async void, so a failed render is observed on a task instead of crashing the host.

Workaround (verified)

Replace both components via DI:

services.Replace(ServiceDescriptor.Transient<ContentToolbar, MySafeContentToolbar>());
services.Replace(ServiceDescriptor.Transient<Breadcrumbs, MySafeBreadcrumbs>());

with subclasses that override OnInitializedAsync without calling base — so the anonymous handlers are never attached — subscribe named handlers instead, and unsubscribe them in Dispose. Skipping base is safe: it only chains to ComponentBase.OnInitializedAsync(), which returns a completed task, and the base OnAfterRenderAsync still performs the initial render.

Implemented as a plain .cs subclass (not a .razor file) so the base component's generated markup is inherited unchanged.

Verified across all affected pages in our application: zero process terminations afterwards.

For a Blazor Web App the replacement must be registered in both the server module and the .Client module, since the layout renders in both.

Markdown supported.
Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)

5 Answer(s)
  • User Avatar
    0
    AI-Bot created
    Support Team ABP Agent
    • Your analysis is correct: subscribing to a scoped PageLayout using anonymous async lambdas (effectively async void handlers for these events) and never unsubscribing can keep dead components alive and can also dispatch StateHasChanged() after the renderer/component scope is already disposed. In a Blazor Web App (InteractiveAuto), that race window is easier to hit, and an exception escaping an async void event handler can indeed become process-terminating.

    • Until there is an upstream fix in Volo.Abp.AspNetCore.Components.Web.LeptonXTheme, the safest application-level mitigation is exactly what you already did: replace the LeptonX theme components via DI and implement proper unsubscribe on dispose.

    Drop-in safe replacements (no markup changes)

    Create code-behind only subclasses (so you inherit the original generated markup) and replace the event hookups with named, non-async-void handlers + IDisposable.

    using System.Collections.Specialized;
    using System.ComponentModel;
    using Microsoft.AspNetCore.Components;
    using Volo.Abp.AspNetCore.Components.Web.LeptonXTheme.Components.ApplicationLayout.Common;
    
    public class MySafeContentToolbar : ContentToolbar, IDisposable
    {
        private NotifyCollectionChangedEventHandler? _toolbarItemsChanged;
        private PropertyChangedEventHandler? _pageLayoutPropertyChanged;
    
        protected override Task OnInitializedAsync()
        {
            _toolbarItemsChanged = ToolbarItemsChanged;
            _pageLayoutPropertyChanged = PageLayoutPropertyChanged;
    
            PageLayout.ToolbarItems.CollectionChanged += _toolbarItemsChanged;
            PageLayout.PropertyChanged += _pageLayoutPropertyChanged;
    
            // IMPORTANT: do NOT call base.OnInitializedAsync() (it attaches the anonymous handlers)
            return Task.CompletedTask;
        }
    
        private void ToolbarItemsChanged(object? sender, NotifyCollectionChangedEventArgs e)
            => _ = InvokeAsync(RenderAsync); // observe on renderer sync context
    
        private void PageLayoutPropertyChanged(object? sender, PropertyChangedEventArgs e)
            => _ = InvokeAsync(StateHasChanged);
    
        public void Dispose()
        {
            if (_toolbarItemsChanged != null)
            {
                PageLayout.ToolbarItems.CollectionChanged -= _toolbarItemsChanged;
            }
            if (_pageLayoutPropertyChanged != null)
            {
                PageLayout.PropertyChanged -= _pageLayoutPropertyChanged;
            }
        }
    }
    
    using System.Collections.Specialized;
    using System.ComponentModel;
    using Microsoft.AspNetCore.Components;
    using Volo.Abp.AspNetCore.Components.Web.LeptonXTheme.Components.ApplicationLayout.Common;
    
    public class MySafeBreadcrumbs : Breadcrumbs, IDisposable
    {
        private NotifyCollectionChangedEventHandler? _breadcrumbItemsChanged;
        private PropertyChangedEventHandler? _pageLayoutPropertyChanged;
    
        protected override Task OnInitializedAsync()
        {
            _breadcrumbItemsChanged = BreadcrumbItemsChanged;
            _pageLayoutPropertyChanged = PageLayoutPropertyChanged;
    
            PageLayout.BreadcrumbItems.CollectionChanged += _breadcrumbItemsChanged;
            PageLayout.PropertyChanged += _pageLayoutPropertyChanged;
    
            // IMPORTANT: do NOT call base.OnInitializedAsync()
            return Task.CompletedTask;
        }
    
        private void BreadcrumbItemsChanged(object? sender, NotifyCollectionChangedEventArgs e)
            => _ = InvokeAsync(StateHasChanged);
    
        private void PageLayoutPropertyChanged(object? sender, PropertyChangedEventArgs e)
            => _ = InvokeAsync(StateHasChanged);
    
        public void Dispose()
        {
            if (_breadcrumbItemsChanged != null)
            {
                PageLayout.BreadcrumbItems.CollectionChanged -= _breadcrumbItemsChanged;
            }
            if (_pageLayoutPropertyChanged != null)
            {
                PageLayout.PropertyChanged -= _pageLayoutPropertyChanged;
            }
        }
    }
    

    Register replacements (as you noted, for Blazor Web App do it in both Server and .Client modules where the layout can render):

    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.DependencyInjection.Extensions;
    using Volo.Abp.AspNetCore.Components.Web.LeptonXTheme.Components.ApplicationLayout.Common;
    
    context.Services.Replace(ServiceDescriptor.Transient<ContentToolbar, MySafeContentToolbar>());
    context.Services.Replace(ServiceDescriptor.Transient<Breadcrumbs, MySafeBreadcrumbs>());
    

    Why this mitigates the host crash

    • No anonymous handlers → you can unsubscribe reliably.
    • No async void propagation → work is scheduled via InvokeAsync(...) and exceptions are observed by the renderer pipeline rather than surfacing as unhandled ThreadPool exceptions.
    • Dispose() ensures dead components stop receiving layout notifications, preventing renders against disposed Autofac scopes.

    Relevant official docs (how to replace LeptonX components / how PageLayout works)

    • https://abp.io/docs/10.6/ui-themes/lepton-x/blazor?UI=BlazorServer&BlazorUI=MudBlazor
    • https://abp.io/docs/10.6/framework/ui/blazor/page-layout?BlazorUI=Blazorise

    If you want this fixed upstream in the theme package, please report it through the official channel so it can be tracked and patched: https://abp.io/contact


    This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    RedEnzian created

    Second part of the Bugreport (related issue but located in ABP source instead of LeptonX) can be found here: https://github.com/abpframework/abp/issues/26075

    Iam not sure if this is the correct place for a LeptonX Bugreport or if i should open a bug issue on github abpframework/abp issue page.

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    hi

    I will check this case.

    Thanks.

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    Hi,

    ContentToolbar and Breadcrumbs subscribe to the scoped PageLayout with anonymous async handlers and never remove them, so a layout notification can still reach a component the renderer has already discarded. This will be fixed in LeptonX 5.7.0.

    Until then, replace both components with subclasses that keep the handlers in fields and unsubscribe on dispose:

    using System;
    using System.Collections.Specialized;
    using System.ComponentModel;
    using System.Threading.Tasks;
    using Volo.Abp.AspNetCore.Components.Web.LeptonXTheme.Components.ApplicationLayout.Common;
    
    namespace MyProject.Blazor.Client.Theming;
    
    public class SafeContentToolbar : ContentToolbar, IDisposable
    {
        private bool _disposed;
    
        protected override Task OnInitializedAsync()
        {
            // Not calling base, it attaches the anonymous handlers.
            PageLayout.ToolbarItems.CollectionChanged += OnToolbarItemsCollectionChanged;
            PageLayout.PropertyChanged += OnPageLayoutPropertyChanged;
            return Task.CompletedTask;
        }
    
        private async void OnToolbarItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
        {
            if (_disposed)
            {
                return;
            }
    
            try
            {
                await RenderAsync();
            }
            catch (ObjectDisposedException)
            {
            }
        }
    
        private async void OnPageLayoutPropertyChanged(object sender, PropertyChangedEventArgs e)
        {
            if (_disposed)
            {
                return;
            }
    
            try
            {
                await InvokeAsync(StateHasChanged);
            }
            catch (ObjectDisposedException)
            {
            }
        }
    
        public void Dispose()
        {
            _disposed = true;
            PageLayout.ToolbarItems.CollectionChanged -= OnToolbarItemsCollectionChanged;
            PageLayout.PropertyChanged -= OnPageLayoutPropertyChanged;
        }
    }
    

    SafeBreadcrumbs : Breadcrumbs is the same shape, subscribing to PageLayout.BreadcrumbItems.CollectionChanged instead, with await InvokeAsync(StateHasChanged) in both handlers.

    Register them in the ConfigureServices of both your .Blazor and .Blazor.Client modules:

    using Microsoft.Extensions.DependencyInjection.Extensions;
    using Volo.Abp.AspNetCore.Components.Web.LeptonXTheme.Components.ApplicationLayout.Common;
    
    context.Services.Replace(ServiceDescriptor.Transient<ContentToolbar, SafeContentToolbar>());
    context.Services.Replace(ServiceDescriptor.Transient<Breadcrumbs, SafeBreadcrumbs>());
    

    You can drop both classes and the registrations once you upgrade to 5.7.0.

    The support ticket is the right place for LeptonX, the theme packages are not in the abpframework/abp repository. The framework side you opened at https://github.com/abpframework/abp/issues/26075 is tracked there.

    Thanks

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    Hi,

    The layout components now unsubscribe from PageLayout when they are disposed, so a notification raised while the page is being torn down can no longer reach a discarded component. This is in for LeptonX 5.7.0.

    Your ticket has been refunded.

    Thanks

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
Boost Your Development
ABP Live Training
Packages
See Trainings
Mastering ABP Framework Book
The Official Guide
Mastering
ABP Framework
Learn More
Mastering ABP Framework Book
Made with ❤️ on ABP v10.8.0-preview. Updated on September 28, 2026, 11:44
1
ABP Assistant
🔐 You need to be logged in to use the chatbot. Please log in first.