Open alex-oswald opened 1 month ago
Nice!
Few questions:
Where/when would ApplicationConfiguration.Initialize();
be invoked?
IIUIC, this design allowes forms and user controls to participate in DI, i.e., this becomes a possibility, right?
public partial class MyForm : Form
{
public MyForm(IServiceProvider serviceProvider)
{
InitializeComponent();
}
}
If my understanding is correct, then this would be good to call out explicitly and provide exmples.
~The above would also affects the VS designer as the designer doesn't understand non-default .ctors, and either the designer has to change (which is unlikely) or we'll need to provide an alternative solution (e.g., this is how I solved it in Git Extensions).~
I am excited for this :)
Are we able to pass additional parameters into the constructor? eg.
private async void button1_Click(object sender, EventArgs e)
{
_logger.LogInformation("Show Form2");
var entityId = GetEntityId(); // Replace with your logic to get the ID
var form = await _formProvider.GetFormAsync<Form2>(entityId);
form.Show();
}
or do we change to our own implementation using method injection?
private async void button1_Click(object sender, EventArgs e)
{
_logger.LogInformation("Show Form2");
var entityId = GetEntityId(); // Replace with your logic to get the ID
var form = await _formProvider.GetFormAsync<Form2>();
form.Initialize(entityId);
form.Show();
}
- Where/when would
ApplicationConfiguration.Initialize();
be invoked?
This is just a helpful method to consolidate what was more verbose. In my current library they are included as an option.
builder.UseWindowsFormsLifetime<Form1>(options =>
{
options.HighDpiMode = HighDpiMode.SystemAware;
options.EnableVisualStyles = true;
options.CompatibleTextRenderingDefault = false;
options.SuppressStatusMessages = false;
options.EnableConsoleShutdown = true;
});
- IIUIC, this design allowes forms and user controls to participate in DI, i.e., this becomes a possibility, right?
public partial class MyForm : Form { public MyForm(IServiceProvider serviceProvider) { InitializeComponent(); } }
If my understanding is correct, then this would be good to call out explicitly and provide exmples.
Yes of course! I updated the proposal to make that more obvious and included an example.
- The above would also affects the VS designer as the designer doesn't understand non-default .ctors, and either the designer has to change (which is unlikely) or we'll need to provide an alternative solution (e.g., this is how I solved it in Git Extensions).
I haven't had any issues with the designer.
I am excited for this :)
Are we able to pass additional parameters into the constructor? eg.
private async void button1_Click(object sender, EventArgs e) { _logger.LogInformation("Show Form2"); var entityId = GetEntityId(); // Replace with your logic to get the ID var form = await _formProvider.GetFormAsync<Form2>(entityId); form.Show(); }
or do we change to our own implementation using method injection?
private async void button1_Click(object sender, EventArgs e) { _logger.LogInformation("Show Form2"); var entityId = GetEntityId(); // Replace with your logic to get the ID var form = await _formProvider.GetFormAsync<Form2>(); form.Initialize(entityId); form.Show(); }
You can't pass parameters in like that. But you can inject services into a forms constructor. You could create some data bag that is registered with the service provider, inject it into one form and update data, then inject it in another form and register some event for when the data changes.
Let me work on adding a section to the proposal that includes a list of services that get registered with the service provider and are available for use.
I haven't had any issues with the designer.
The designer won't be able to render this form on the design surface unless there's a default .ctor:
public partial class MyForm : Form
{
public MyForm(IServiceProvider serviceProvider)
{
InitializeComponent();
}
}
This means that develoeprs will have to create default .ctors by hand (i.e., boilerplate).
This should be in another assembly, not the core hosting assembly. Microsoft.Extensions.Hosting.Winforms.
This should be in another assembly, noy the core hosting assembly. Microsoft.Extensions.Hosting.Winforms.
Agreed. This depends on the windows target framework so it shouldn't be included in https://www.nuget.org/packages/Microsoft.Extensions.Hosting.
This should be in another assembly, noy the core hosting assembly. Microsoft.Extensions.Hosting.Winforms.
Agreed. This depends on the windows target framework so it shouldn't be included in https://www.nuget.org/packages/Microsoft.Extensions.Hosting.
I haven't had any issues with the designer.
The designer won't be able to render this form on the design surface unless there's a default .ctor:
public partial class MyForm : Form { public MyForm(IServiceProvider serviceProvider) { InitializeComponent(); } }
This means that develoeprs will have to create default .ctors by hand (i.e., boilerplate).
Hmm. I don't have default constructors in my sample apps and the designer works just fine with those forms. What exactly isn't supposed to work?
An implementation side note:
[STAThread]
attribute on it. This is required so any COM objects created during initilization get initialized in the correct apartment for how they are going to be used later.ApplicationConfiguration.Initialize
- it needs to do some important early initialization that other initialization code may require to be set up before the host starts running.You may be able to sideload these into UseWindowsFormsLifetime
but maybe it should be more explicit that setting process-wide defaults are done as a side effect? I'd suggest allowing the user to call initialization separately and (maybe UseWindowsFormsLifetime
does it implicitly if the user doesn't do it manually, instead of throwing, or something like this).
Would there be impacts on our AOT work?
An implementation side note:
- The thread needs to be turned into STA really early since you no longer have the
[STAThread]
attribute on it. This is required so any COM objects created during initilization get initialized in the correct apartment for how they are going to be used later.- Same goes for
ApplicationConfiguration.Initialize
- it needs to do some important early initialization that other initialization code may require to be set up before the host starts running.You may be able to sideload these into
UseWindowsFormsLifetime
but maybe it should be more explicit that setting process-wide defaults are done as a side effect? I'd suggest allowing the user to call initialization separately and (maybeUseWindowsFormsLifetime
does it implicitly if the user doesn't do it manually, instead of throwing, or something like this).
This should all be taken care of early on. See the below snippet of my current implementation. WinForms hasn't been my main focus for a while though so if I'm missing something let me know.
public Task StartAsync(CancellationToken cancellationToken)
{
_applicationStoppingRegistration = _hostApplicationLifetime.ApplicationStopping.Register(state =>
{
((WindowsFormsHostedService)state).OnApplicationStopping();
},
this);
Thread thread = new(StartUiThread)
{
Name = "WindowsFormsLifetime UI Thread"
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
private void StartUiThread()
{
Application.SetHighDpiMode(_options.HighDpiMode);
if (_options.EnableVisualStyles)
{
Application.EnableVisualStyles();
}
Application.SetCompatibleTextRenderingDefault(_options.CompatibleTextRenderingDefault);
Application.ApplicationExit += OnApplicationExit;
// Don't autoinstall since we are creating our own
WindowsFormsSynchronizationContext.AutoInstall = false;
// Create the sync context on our UI thread
_syncContextManager.SynchronizationContext = new WindowsFormsSynchronizationContext();
SynchronizationContext.SetSynchronizationContext(_syncContextManager.SynchronizationContext);
var applicationContext = _serviceProvider.GetService<ApplicationContext>();
PreApplicationRunAction?.Invoke(_serviceProvider);
Application.Run(applicationContext);
}
Would there be impacts on our AOT work?
I'm not sure. The implementation only relies on the windows target framework and the Microsoft.Extensions.Hosting
nuget package.
This should all be taken care of early on. See the below snippet of my current implementation.
The main problem of starting a separate thread is that developers may assume the main thread (on which the application is started and configured) will be used as the UI thread and they may initialize things on the main thread during container initialization. Besides having native objects attached to the wrong thread this can be fatal for other reasons as well - we once have had this cause the finalizer thread to deadlock because WinForms was called during initialization but this initializing thread wasn't afterwards becoming the UI thread and the internal COM objects WinForms accessed could not return to the startup thread for finalization, effectively hanging the finalizer forever and causing every finalizable object to leak.
Another issue is that some "application startup" calls WinForms needs to do are process-wide and not thread-wide, so doing them when starting up your thread may be too late, and any code needing to interact with WinForms before that point may see wrong defaults (e.g. Font measurements), or worse, permanently lock-in the wrong defaults (some defaults can't be changed once certain WinForms calls have been made).
Currently WinForms doesn't have a good way to detect or prevent calls that happen "too early", i.e. before the defaults have been configured or the UI thread has been selected. Ideally WinForms would keep track of initialization and throw exceptions if WinForms APIs are used before the basic configuration happened, but I doubt that's an easy change to make now.
This should all be taken care of early on. See the below snippet of my current implementation.
The main problem of starting a separate thread is that developers may assume the main thread (on which the application is started and configured) will be used as the UI thread and they may initialize things on the main thread during container initialization. Besides having native objects attached to the wrong thread this can be fatal for other reasons as well - we once have had this cause the finalizer thread to deadlock because WinForms was called during initialization but this initializing thread wasn't afterwards becoming the UI thread and the internal COM objects WinForms accessed could not return to the startup thread for finalization, effectively hanging the finalizer forever and causing every finalizable object to leak.
Another issue is that some "application startup" calls WinForms needs to do are process-wide and not thread-wide, so doing them when starting up your thread may be too late, and any code needing to interact with WinForms before that point may see wrong defaults (e.g. Font measurements), or worse, permanently lock-in the wrong defaults (some defaults can't be changed once certain WinForms calls have been made).
Currently WinForms doesn't have a good way to detect or prevent calls that happen "too early", i.e. before the defaults have been configured or the UI thread has been selected. Ideally WinForms would keep track of initialization and throw exceptions if WinForms APIs are used before the basic configuration happened, but I doubt that's an easy change to make now.
All good points. I will add these to the Risks section. Perhaps documentation should explain this clearly. An analyzer could also be packaged that warns the user of doing anything related to winforms before the host starts up.
There is also the potential that some service tries to interact with the winforms thread before the hosted service starts up. Perhaps I should find a way to guarantee it starts before other hosted services.
To all these points though, developers would of course need to follow some guidance when using this feature for it to work properly, like anything else.
Hmm. I don't have default constructors in my sample apps and the designer works just fine with those forms. What exactly isn't supposed to work?
I stand corrected. This must have changed because it used to fail. That's great!
All good points. I will add these to the Risks section. Perhaps documentation should explain this clearly. An analyzer could also be packaged that warns the user of doing anything related to winforms before the host starts up.
I still think you underestimate the problem. Due to the builder pattern employed for setting up and configuring everything "before the host starts up" can involve a lot of user-defined delegates, much of which is executed during startup but on the main thread, and delegates are not something an analyzer can understand well. Also any constructor of objects registered in the service container is part of the startup; I don't think an analyzer can reasonably inspect the constructor of third party classes to determine whether its safe to register them.
Thinking about this, how will people actually write any DI service interacting with WinForms? The proposed API does not expose the UI thread and as mentioned above you have no control or indication if a service is constructed on the UI thread or on the main thread (for service constructors it'll be more or less random, depending on who resolves the service first).
I think this proposal needs to, at the very least, include a way for a service to run some initialization code on the UI thread. Maybe something in the direction of IHostedService for UI. If you have that you could say (and document) its the only supported way and starting to interact with WinForms any other way before the host finished startup is "unsupported". Still not great, since it provides no means to check if you comply to the rules, but I have no idea how to retrofit that and I understand people prefer an unsafe/brittle implementation over no implementation.
Personally I think turning the main thread into the UI thread is the better solution, even if its more work implementing it. (Though of course then you have the inverse problem, that all services will default to the WinForms SynchronizationContext, even if they are UI-agnostic - but on Desktop Framework this was always chosen as the default when designing new features, probably because its the more robust thing to do.)
IIUIC, this design allowes forms and user controls to participate in DI, i.e., this becomes a possibility, right?
Yes of course! I updated the proposal to make that more obvious and included an example.
This runs into the exact problem and is a great example of people not understanding the underlying issue.
All good points. I will add these to the Risks section. Perhaps documentation should explain this clearly. An analyzer could also be packaged that warns the user of doing anything related to winforms before the host starts up.
I still think you underestimate the problem. Due to the builder pattern employed for setting up and configuring everything "before the host starts up" can involve a lot of user-defined delegates, much of which is executed during startup but on the main thread, and delegates are not something an analyzer can understand well. Also any constructor of objects registered in the service container is part of the startup; I don't think an analyzer can reasonably inspect the constructor of third party classes to determine whether its safe to register them.
I understand the analyzer issues. Thanks for pointing those out.
Thinking about this, how will people actually write any DI service interacting with WinForms? The proposed API does not expose the UI thread and as mentioned above you have no control or indication if a service is constructed on the UI thread or on the main thread (for service constructors it'll be more or less random, depending on who resolves the service first).
I updated the proposal to include the services that are registered with the service provider, including services that hold a reference to the WindowsFormsSynchronizationContext
and another that provides the ability to invoke calls on the Windows Forms thread from anywhere.
I think this proposal needs to, at the very least, include a way for a service to run some initialization code on the UI thread. Maybe something in the direction of IHostedService for UI. If you have that you could say (and document) its the only supported way and starting to interact with WinForms any other way before the host finished startup is "unsupported". Still not great, since it provides no means to check if you comply to the rules, but I have no idea how to retrofit that and I understand people prefer an unsafe/brittle implementation over no implementation.
Good idea. I will work on that.
Personally I think turning the main thread into the UI thread is the better solution, even if its more work implementing it. (Though of course then you have the inverse problem, that all services will default to the WinForms SynchronizationContext, even if they are UI-agnostic - but on Desktop Framework this was always chosen as the default when designing new features, probably because its the more robust thing to do.)
I think this is a bad idea, for the reason you pointed out. I implemented this solution the way I did so the UI thread isn't easy to saturate. Consumers should follow best practices if they decide to use the library.
This is a great suggestion. We really want to take this (with some more conversation about design and implementation details) , but given current priorities we think this would fit much better in the .NET 10 timeframe. Please continue the conversation! We really like where this is going 😄
@merriemcgaw this could probably be taken in .NET 9 under the ExperiementalAttribute
, meaning that if it doesn't pan out - it can be completely reworked or nuked in future .NET versions. Assuming, of course, @alex-oswald is willing to do the heavy lifting.
I'll check with the team and circle back.
@RussKie Yes, I am happy to do the heavy lifting. I will start working on the integration in a fork while keeping this proposal up to date. I'm going to have questions for sure. @merriemcgaw @KlausLoeffelmann should I ask detailed questions here for visibility, or would you rather me reach out via Teams?
@alex-oswald What public API changes would this need in WinForms itself? Can we detail the proposed changes for that part of it so we can figure out the mechanics of possibly doing a preview for .NET 9?
@alex-oswald What public API changes would this need in WinForms itself? Can we detail the proposed changes for that part of it so we can figure out the mechanics of possibly doing a preview for .NET 9?
There shouldn't need to be any changes to current APIs. There would only be additions.
@alex-oswald What public API changes would this need in WinForms itself? Can we detail the proposed changes for that part of it so we can figure out the mechanics of possibly doing a preview for .NET 9?
There shouldn't need to be any changes to current APIs. There would only be additions.
@alex-oswald So no modifications of any existing types?
@alex-oswald What public API changes would this need in WinForms itself? Can we detail the proposed changes for that part of it so we can figure out the mechanics of possibly doing a preview for .NET 9?
There shouldn't need to be any changes to current APIs. There would only be additions.
@alex-oswald So no modifications of any existing types?
Correct. The proposal is essentially to move the library I've created at https://github.com/alex-oswald/WindowsFormsLifetime into .NET officially, with some tweaks and name changes. Since the current library works standalone, nothing should need to be modified. Though I'm curious on what the process is to add support for its dependency. Since this proposal is the glue between WinForms and the .NET Generic Host, it has a dependency on Microsoft.Extensions.Hosting
which is in the runtime repo.
it has a dependency on
Microsoft.Extensions.Hosting
which is in the runtime repo.
This would be the most challenging part to work out:
Windows Forms SDK is a part of the Windows Desktop SDK, which is installed alongside .NET SDK. However, M.E.H needs to be pulled in separately, which essentially creates an external dependency for Windows Forms...
This sounds like this feature will itself need to become an OOB offering, i.e., not shipped inbox but published to NuGet in the same fashion as M.E.H is (and other OOB packages).
it has a dependency on
Microsoft.Extensions.Hosting
which is in the runtime repo.This would be the most challenging part to work out:
Windows Forms SDK is a part of the Windows Desktop SDK, which is installed alongside .NET SDK. However, M.E.H needs to be pulled in separately, which essentially creates an external dependency for Windows Forms...
This sounds like this feature will itself need to become an OOB offering, i.e., not shipped inbox but published to NuGet in the same fashion as M.E.H is (and other OOB packages).
Yes I agree. I think that is what @davidfowl was getting at when he said it should live in another assembly, such as Microsoft.Extensions.Hosting.WinForms
. Now the question is, where should the code live? In the winforms
or runtime
repo?
IMHO, dotnet/winforms is the place to be as the functionality is inherently tied to Windows Forms and any tests would be here too. My 2c, but the team may have different views on this.
I'd like it to live in dotnet/winforms as it helps iterate on it faster.
Background and motivation
The .NET Generic Host is the standard for developing .NET applications that leverage common .NET libraries such as Dependency Injection, Configuration and Logging. Currently, .NET does not provide a built in way for Windows Forms developers to use the Generic Host. I believe most Windows Forms developers use the standard template that comes with Visual Studio when creating Windows Forms applications. This template uses old .NET patterns and isn't up to date with the new Minimal API pattern added to .NET.
While working in the Enterprise world, I worked on a lot of internal Windows Forms applications that we were modernizing and upgrading to .NET Core at the time. There was no easy way to use dependency injection, configuration, logging, and background task support. This need drove me to write the WindowsFormsLifetime library that provides extensions for the Generic Host to support Windows Forms and its lifetime. I believe there is a want and need for this functionality world wide, and building it into .NET would allow Windows Forms developers everywhere the chance to use modern patterns and libraries they use in other places.
High Level
Windows Forms Lifetime works by registering a custom implementation of
IHostLifetime
for the generic host. It also registers anIHostedService
that creates and manages the GUI thread that Windows Forms works on top of, and hooks into Windows FormsApplication
context to shut down the host when the application exits.Going Deep
A high level explanation of how Windows Forms Lifetime was described above. This section will go into detail of the inner workings. Lets hope I understand this the same as when I wrote the library since it's been 4 years since my initial proof of concept.
To understand how this works, you need to have a beginners understanding of how Windows Forms works internally. WinForms uses an
Application
class with a bunch of static methods that manages the application and the UI thread. TheApplication
class helps manage anApplicationContext
. This context controls the application by listening to the main forms close event and then exits the GUI threads message loop that is reference by theThreadContext
. When you write a Windows Forms application, you invokeApplication.Run()
to start the GUI thread and show the main form. When you exit the main form, theApplicationContext
is notified and exits the GUI thread. Using this information, we can modify howApplication
works and use our own GUI thread. So lets get into it.The Generic Host implements a singleton of
IHostLifetime
. In the standard Generic Host implementation,IHostLifetime
is never messed with, but since we want to control the lifetime of the application, we first create a custom implementation of the interface calledWindowsFormsLifetime
. Injecting our own implementation ofIHostLifetime
overrides the default implementation and lets us control when the Generic Host shuts down.We also add the
WindowsFormsHostedService
IHostedService
implementation. This is really the core of the library. The Generic Host starts up everyIHostedService
registered and they can effectively run for the entirety of the application. WhenWindowsFormsHostedService
is started, it creates a thread for the GUI and passes in theStartUiThread
method. TheStartUiThread
method also registers a callback withApplication.ApplicationExit
that signals the Generic Host to shutdown when the WinForms application exits, i.e. the main form is closed. TheStartUiThread
also captures theWindowsFormsSynchronizationContext
for the GUI thread and saves it to the singletonWindowsFormsSynchronizationContextProvider
for easy retrieval later using dependency injection. Lastly, theStartUiThread
method gets theApplicationContext
registered with the service provider and passes it to theApplication.Run()
invocation.As mentioned above, an instance of
WindowsFormsSynchronizationContextProvider
is registered with the service provider for easy retrieval in various parts of the application. This class also inherits theIGuiContext
interface that exposes methods to invokeAction
's andFunc
's with theWindowsFormsSynchronizationContext
to ensure they are invoked on the GUI thread.An implementation of
IFormProvider
is also registered with the service provider. This implementation provides an easy way to fetch a new instance of a form from the service provider and ensures it is created on the GUI thread. It exposes a few methods such as,GetFormAsync<T>()
andGetFormAsync<T>(IServiceScope scope)
allowing the creation of scoped forms. The advantage here is that you can create a form with the same scope as aDbContext
instance. This allows theDbContext
instance to be disposed of when the form is closed.API Proposal
The main API proposal adds extension methods to the Generic Host. A few
IHostApplicationBuilder
extensions would be created in theMicrosoft.Extensions.Hosting
namespace.A few
IServiceCollection
extensions would be created in theMicrosoft.Extensions.DependencyInjection
namespace.Registered Services
IOptions<WindowsFormsLifetimeOptions>
IHostApplicationLifetime
viaWindowsFormsLifetime
Replaces the hosts singleton lifetime implementation.
IWindowsFormsSynchronizationContextProvider
viaWindowsFormsSynchronizationContextProvider
Holds a reference to the
WindowsFormsSynchronizationContext
for the Windows Forms thread.IWindowsFormsThreadContext
viaWindowsFormsSynchronizationContextProvider
Provides methods to marshal calls to the
WindowsFormsSynchronizationContext
, i.e. the Windows Forms thread.IFormProvider
viaFormProvider
API Usage
Currently, when you create a Windows Forms project in C# using the template, you get the following
Program.cs
file:Using the new API,
Program.cs
would look like the following:This allows the use of dependency injection. Here is an example of a forms code.
https://github.com/alex-oswald/WindowsFormsLifetime/blob/main/samples/SampleApp/Form1.cs
Use Cases
Windows Forms Blazor Hybrid
An app with hosted services that run background tasks.
Using Entity Framework with a Sqlite database.
Alternative Designs
IHostApplicationBuilder
extension methods likeUseWindowsFormsLifetime
could be simply namedUseWindowForms
.IGuiContext
toIWindowsFormsThreadContext
.Risks
Low, this should just enable easier modern development. Possible Windows Forms thread issues, though enough testing will mitigate this.
Will this feature affect UI controls?
No, this will not affect UI controls.