doc/agent-logs/cshells-changes-summary.md
CShells ChangesThere were two separate runtime problems I was addressing in CShells:
Duplicate endpoint registration during startup
System.InvalidOperationException: Not allowed to configure endpoints after startup! Culprit: [Verbs()]ShellsReloaded flow.Ambiguous activation of WebRoutingShellResolver
Unable to activate type 'CShells.AspNetCore.Resolution.WebRoutingShellResolver'. The following constructors are ambiguous: ...src/CShells.AspNetCore/Configuration/CShellsBuilderExtensions.csI made two important changes here.
Instead of registering WebRoutingShellResolver by type alone, I changed the resolver pipeline registration to explicitly construct it with:
IShellHostWebRoutingShellResolverOptionsPreviously, ShellEndpointRegistrationHandler was registered separately as:
INotificationHandler<ShellActivated>INotificationHandler<ShellDeactivating>INotificationHandler<ShellRemoved>INotificationHandler<ShellsReloaded>I changed that to:
ShellEndpointRegistrationHandlerShellActivatedEndpointRegistrationForwarderShellDeactivatingEndpointRegistrationForwarderShellRemovedEndpointRegistrationForwarderShellsReloadedEndpointRegistrationForwarderThe factory-based resolver registration was to fix the WebRoutingShellResolver constructor ambiguity. Since DI could resolve multiple constructors, it was not safe to rely on type activation. Explicit construction forces one intended activation path.
The forwarder-based notification registration was needed because the endpoint deduplication logic depends on shared in-memory state. Registering the same implementation separately for multiple closed generic interfaces can lead to different singleton instances being used per interface type. That breaks state sharing.
By routing all notifications through one shared concrete ShellEndpointRegistrationHandler, the deduplication state is preserved across ShellActivated, ShellDeactivating, ShellRemoved, and ShellsReloaded.
src/CShells.AspNetCore/Notifications/ShellEndpointRegistrationHandler.csThis file contains the main startup-endpoint fix.
I added:
IShellRuntimeStateAccessorIsEndpointGraphCurrent(...) guardI also changed notification handling so that:
ShellActivated registers endpoints and tracks the applied generationShellDeactivating removes endpoints and forgets the generationShellRemoved removes endpoints and forgets the generationShellsReloaded now:
Finally, I added the forwarder classes at the bottom of the file.
This was the core fix for the duplicate startup endpoint-registration path.
The failure mode was effectively:
ShellsReloaded is publishedThat second pass was a problem for FastEndpoints, which rejects endpoint configuration after startup has progressed beyond its expected window.
To fix that, I made ShellEndpointRegistrationHandler state-aware. It now checks whether the currently mapped shell IDs and applied generations already match runtime state. If so, it skips the aggregate rebuild instead of remapping the same endpoints again.
That preserves the ability to rebuild endpoints when shells truly change, while avoiding no-op startup remaps.
src/CShells/Hosting/ShellStartupHostedService.csI changed startup publication of ShellsReloaded to use a custom notification strategy:
startupShellsReloadedStrategyI also added a private StartupShellsReloadedNotificationStrategy implementation that filters out ASP.NET Core notification handlers during the startup ShellsReloaded publication.
This was a startup-specific safety measure.
Even after adding deduplication logic, I wanted to prevent the startup-wide ShellsReloaded from immediately driving the ASP.NET Core endpoint-remapping path in the same startup cycle.
The intent was:
ShellsReloaded as a meaningful system eventShellsReloaded observersSo this was not a general suppression of ShellsReloaded; it was a targeted startup filter to avoid duplicate endpoint mapping during application bootstrap.
src/CShells/Resolution/ResolverPipelineBuilder.csI added support for registering resolver strategies with a factory:
Use<TStrategy>(Func<IServiceProvider, TStrategy> factory, int? order = null)I also updated internal strategy registration so each strategy registration can now hold:
During pipeline build, the resolver strategy can now be registered using that explicit factory.
This was necessary to support the explicit-construction fix for WebRoutingShellResolver in CShellsBuilderExtensions.
Once I decided not to rely on DI constructor selection for that resolver, the pipeline needed a way to say:
register this resolver strategy, but instantiate it exactly this way
Without factory support in ResolverPipelineBuilder, there was no clean way to do that.
These test changes matter because they encode the design intent behind the production changes.
tests/CShells.Tests/Integration/AspNetCore/ApplicationBuilderExtensionsTests.csI added a regression test that verifies:
ShellsReloaded with the same applied generation stateI also added:
Because the production bug was about duplicate endpoint registration, I wanted a test that directly proves the bad sequence no longer happens.
The test protects the scenario that previously triggered the FastEndpoints startup exception.
tests/CShells.Tests/Integration/AspNetCore/ServiceCollectionExtensionsTests.csI added a regression test that verifies the DI registration shape now consists of:
ShellEndpointRegistrationHandlerThe shared-state design only works if all notification types ultimately delegate to the same concrete handler instance.
This test protects that registration shape so it is not accidentally simplified back into a broken form later.
The CShells changes were meant to make startup behavior more correct and deterministic.
I was trying to prevent this sequence:
FastEndpoints rejects the second configuration passSo I:
I was trying to prevent DI from making an ambiguous constructor choice when building WebRoutingShellResolver.
So I:
These changes were intended to move CShells away from startup behavior that depended on:
and toward behavior that is:
That is why each of those CShells files changed the way they did.