Skip to content

Validate service provider scopes#18492

Closed
Foda wants to merge 13 commits intomainfrom
foda/ValidateScopes
Closed

Validate service provider scopes#18492
Foda wants to merge 13 commits intomainfrom
foda/ValidateScopes

Conversation

@Foda
Copy link
Copy Markdown
Contributor

@Foda Foda commented Nov 2, 2023

Description of Change

Validate service provider scopes

Issues Fixed

Fixes #11457

@Foda Foda requested a review from PureWeen November 2, 2023 19:57
@Foda Foda requested a review from a team as a code owner November 2, 2023 19:57
@Foda Foda requested a review from rmarinho November 2, 2023 19:57
@PureWeen PureWeen requested review from Eilon, danroth27, eerhardt and mattleibow and removed request for rmarinho November 2, 2023 22:08
@Eilon
Copy link
Copy Markdown
Contributor

Eilon commented Nov 2, 2023

Wow, does this actually work? Is the trick that it already pre-creates a scope during initialization? I wonder, are there any downsides to that?

There are some random test failures, but also I see two legit ones where probably just the tests need to be updated:

1. CreatesServiceProviderByDefault

Assert.IsType() Failure: Value is not the exact type
Expected: typeof(Microsoft.Extensions.DependencyInjection.ServiceProvider)
Actual: typeof(Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope)

Stack trace
at Microsoft.Maui.UnitTests.Hosting.HostBuilderConfigureContainerTests.CreatesServiceProviderByDefault() in /Users/builder/azdo/_work/1/s/src/Core/tests/UnitTests/Hosting/HostBuilderConfigureContainerTests.cs:line 17
at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor)
at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)

2. ConfigureContainerCanReplaceIServiceProvider

Assert.IsType() Failure: Value is not the exact type
Expected: typeof(Microsoft.Maui.UnitTests.Hosting.HostBuilderConfigureContainerTests+MyServiceProvider)
Actual: typeof(Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope)

Stack trace
at Microsoft.Maui.UnitTests.Hosting.HostBuilderConfigureContainerTests.ConfigureContainerCanReplaceIServiceProvider() in /Users/builder/azdo/_work/1/s/src/Core/tests/UnitTests/Hosting/HostBuilderConfigureContainerTests.cs:line 37
at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor)
at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)

@Eilon Eilon added the area-core-hosting Extensions / Hosting / AppBuilder / Startup label Nov 2, 2023
@Dreamescaper
Copy link
Copy Markdown

Dreamescaper commented Nov 2, 2023

As far as I understand, this PR enables scopes validation during the development of MAUI itself.
But the original issue is about validation in Debug mode for user's apps. So I'd suggest to keep that linked issue open after merging this PR.

Btw, does it make sense to include registered services validation (i.e. that all the dependencies are registered for all the services)?
You can do that like that:

 containerBuilder.BuildServiceProvider(new ServiceProviderOptions
{
	ValidateScopes = true,
	ValidateOnBuild = true
});

@Dreamescaper
Copy link
Copy Markdown

There's a potential issue with the current implementation.
ServiceProvider is disposed when MauiApp is disposed. But in this case it's not the root container which is disposed, but only scoped one. I assume that will lead to singleton services being never disposed.

@Eilon
Copy link
Copy Markdown
Contributor

Eilon commented Nov 3, 2023

Indeed, @Dreamescaper points are I think both critical:

  1. It must be user-configurable to decide whether scope validation is enabled. For example, it's often enabled in debug mode, and disabled in release mode (there's a perf cost to it)
  2. Gotta dispose the disposables!

@PureWeen
Copy link
Copy Markdown
Member

PureWeen commented Nov 3, 2023

Indeed, @Dreamescaper points are I think both critical:

  1. It must be user-configurable to decide whether scope validation is enabled. For example, it's often enabled in debug mode, and disabled in release mode (there's a perf cost to it)
  2. Gotta dispose the disposables!
  1. yea, I think this is a .NET9 problem though since we've closed shop for .NET8. I don't think we can move the cheese like this currently.

As far as I understand, this PR enables scopes validation during the development of MAUI itself.

Yea, the first step here is just making sure that MAUI itself isn't violating the rules so if users opt in for the validation through the mechanisms that @Foda has shown in the PR that it'll work for users.

@Foda
Copy link
Copy Markdown
Contributor Author

Foda commented Nov 8, 2023

/rebase

@github-actions github-actions bot force-pushed the foda/ValidateScopes branch from 56b6176 to 3f1ce7b Compare November 8, 2023 21:25
: _services.BuildServiceProvider();

MauiApp builtApplication = new MauiApp(serviceProvider);
var appScope = serviceProvider.CreateScope();
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.

Why are we always creating a scope for the application? That doesn't seem necessary.

cc @davidfowl @halter73

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.

I feel like the core problem is really that some MAUI services have invalid scope dependencies (like a scoped service depending on a transient service or similar). And we never found that because we never had scope validation enabled. That's what my initial investigation from a long time ago revealed: #11457 (comment)

Copy link
Copy Markdown
Member

@PureWeen PureWeen Nov 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comes up for services that have the possibility to be scoped at the window or the application level.

Currently we only create scopes for each window.

The scenario that @Eilon articulated in the original issue is probably the only current case where this comes up.

The IDispatcher is a scoped service that returns an IDispatcherat the Application level and then a different IDispatcher at the window level, depending on where you are and what you are trying to do.

Before we've created any windows, the IDispatcher at the application level is used for scenarios that need a dispatcher. The exception that you see in @Eilon 's comment comes from us retrieving the Application level dispatcher before any windows have been created. The Application dispatcher is also relevant for scenarios where you might not be inside a window.

The other approach could be to setup a scoped container that we use specifically for app level scoped services. We can use that as our fall back so that the app level container remains the root container and not scoped. This would probably maintain the spirit/purpose of this validation a bit more accurately, whereas doing it this way would probably mask the validation.

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.

Pushed up a variation of this PR based on the notes I made above ^

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.

I'm not sure if there are any negative implications to this so I"ll need to defer to @eerhardt and @davidfowl regarding always creating a scope.

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.

So even though MauiApp.Services remains an unscoped provider, the root MauiContext.Services now always has an application-level scope? What about code already calling the public MauiContext ctor with MauiApp.Services? Also, did we miss a call to the MauiContext ctor in our own Tizen code?

If this is the direction we want to go, I think we should update the API to be clearer about where it's exposing an application-level scoped provider vs unscoped root provider. Or at least update the doc comments and make a breaking change announcement.

@PureWeen PureWeen requested a review from eerhardt November 21, 2023 11:38
@PureWeen
Copy link
Copy Markdown
Member

/rebase

@github-actions github-actions bot force-pushed the foda/ValidateScopes branch from 3f1ce7b to 011f4d5 Compare November 30, 2023 17:13
Copy link
Copy Markdown
Contributor

@Eilon Eilon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks fairly reasonable to me in terms of enabling scope validation.

In ASP.NET Core the system automatically enables scope validation in "Development" mode (this is orthogonal to Debug/Release). In MAUI we don't have such a concept, but we do have Debug/Release.

Would we consider a change to the default template code to have #if DEBUG di.EnableScopeValidation=true; ?

Obviously that isn't real code, but it would be nice if we:

  1. Make it very easy for people to enable (the current pattern has nearly zero discoverability, and is not easy)
  2. Enable it in the template for DEBUG mode. This way there's no breaking change, and it's template code, so very easy to change

Thoughts?

@danroth27 danroth27 removed their request for review December 2, 2023 02:12
Copy link
Copy Markdown
Member

@halter73 halter73 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless I'm missing something, resolving scoped services will always be "valid" after this change unless there are resolved using MauiApp.Services.GetService(type). Is this common? If not, I don't see the point of the additional complexity of exposing both unscoped and application scoped providers. We might as well make MauiApp.Services scoped too and forget about validation altogether.

: _services.BuildServiceProvider();

MauiApp builtApplication = new MauiApp(serviceProvider);
var appScope = serviceProvider.CreateScope();
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.

So even though MauiApp.Services remains an unscoped provider, the root MauiContext.Services now always has an application-level scope? What about code already calling the public MauiContext ctor with MauiApp.Services? Also, did we miss a call to the MauiContext ctor in our own Tizen code?

If this is the direction we want to go, I think we should update the API to be clearer about where it's exposing an application-level scoped provider vs unscoped root provider. Or at least update the doc comments and make a breaking change announcement.

foreach (var instance in initServices)
{
instance.Initialize(builtApplication.Services);
instance.Initialize(appScope.ServiceProvider);
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.

If we're passing a scoped service provider into IMauiInitializeService, what's the point of IMauiInitializeScopedService?

Copy link
Copy Markdown
Member

@PureWeen PureWeen Dec 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright shifted things around a bit. I'm pretty sure the bits that were implementing IMauiInitializeService should have actually been implementingIMauiInitializeScopedService

@mattleibow ? I noticed we add the ScopedService version here #2862

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.

Hmmm though it looks like this change now causes requests for the IDispatcher via MauiApp.Services to fail because it's not initialized on the right thread.

So, I'll need to think on that for a bit or just go back to the path of having MauiApp.Services just be a scoped service.

@PureWeen
Copy link
Copy Markdown
Member

PureWeen commented Dec 6, 2023

Unless I'm missing something, resolving scoped services will always be "valid" after this change unless there are resolved using MauiApp.Services.GetService(type). Is this common? If not, I don't see the point of the additional complexity of exposing both unscoped and application scoped providers. We might as well make MauiApp.Services scoped too and forget about validation altogether.

The main scenario I think that validation will cover is if you resolve a singleton service with a scoped constructor argument. Something like this

	public static class MauiProgram
	{
		public static MauiApp CreateMauiApp()
		{
			var builder = MauiApp
				.CreateBuilder()
			        .UseMauiMaps()
			        .UseMauiApp<App>();

			builder.ConfigureContainer(new DefaultServiceProviderFactory(new ServiceProviderOptions
			{
				ValidateOnBuild = true,
				ValidateScopes = true,
			}));

			builder.Services.AddSingleton(typeof(Singleton));
			builder.Services.AddScoped(typeof(ScopedService));			
			
			// crashes because the ctor on singleton has a scoped service
			var app = builder.Build();

			app.Services.CreateScope().ServiceProvider.GetServices<Singleton>();
			return app;
		}
	}

	public class ScopedService
	{

	}

	public class Singleton
	{
		public Singleton(ScopedService service) 
		{ 
		}

	}

I don't see the point of the additional complexity of exposing both unscoped and application scoped providers. We might as well make MauiApp.Services scoped too and forget about validation altogether.

This was our initial approach
a8eb11d

but @eerhardt expressed concern so I took at stab at modifying this PR so the MauiApp.Services remains unchanged and then we just modify at a more specific point, which is when we create the MAuiContext. The MauiContext being scoped is already somewhat of a pattern inside MAUI. For example, we scope a new MauiContext for each window that gets created

public static IMauiContext MakeWindowScope(this IMauiContext mauiContext, NativeWindow platformWindow, out IServiceScope scope)
{
scope = mauiContext.Services.CreateScope();
#if ANDROID
var scopedContext = new MauiContext(scope.ServiceProvider, platformWindow);
#else
var scopedContext = new MauiContext(scope.ServiceProvider);
#endif
scopedContext.AddWeakSpecific(platformWindow);
#if ANDROID
scopedContext.AddSpecific(new NavigationRootManager(scopedContext));
#endif
#if WINDOWS
scopedContext.AddSpecific(new NavigationRootManager(platformWindow));
#endif
return scopedContext;
}

so it somewhat made sense to me that we could do the same with the MauiContext that gets passed in at the application level.

all that being said :-) I don't quite see the difference between the complexity of this approach vs our first approach just making the MauiApp.Services scoped.

The only difference I can think of there is that if someone were to do

MauiApp.Services.GetService<ScopedService>

if we keep MauiApp.Services as the root container that will throw an exception whereas if the root container is also the scoped service then it won't throw an exception. So, it does protect if someone uses the MauiApp level service to service locate a scoped service.

Also, did we miss a call to the MauiContext ctor in our own Tizen code?

I moved the code for creating the app scope to MakeApplicationScope which should now simplify and just cover all cases

@PureWeen PureWeen requested a review from halter73 December 6, 2023 18:35
@PureWeen PureWeen marked this pull request as draft December 7, 2023 03:12
@PureWeen
Copy link
Copy Markdown
Member

/rebase

@github-actions github-actions bot force-pushed the foda/ValidateScopes branch from 136624c to 6fcde0d Compare December 23, 2023 16:28
@PureWeen
Copy link
Copy Markdown
Member

Trying a different approach here
#19593

@PureWeen PureWeen closed this Jan 22, 2024
@github-actions github-actions bot locked and limited conversation to collaborators Feb 22, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-core-hosting Extensions / Hosting / AppBuilder / Startup

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enable Scope Validation for dependency injection in .NET MAUI during development

7 participants