diff --git a/Directory.Packages.props b/Directory.Packages.props index 5bf587da830..55906cb7fbd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,6 +7,8 @@ + + diff --git a/docs/application-builder/benchmarks/Program.cs b/docs/application-builder/benchmarks/Program.cs new file mode 100644 index 00000000000..9a55900958c --- /dev/null +++ b/docs/application-builder/benchmarks/Program.cs @@ -0,0 +1,448 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Drawing; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Windows.Forms; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.WinForms; + +namespace WinFormsApplicationBuilder.Benchmarks; + +/// +/// Runs STA-aware startup, shutdown, allocation, and resource measurements. +/// +internal static partial class Program +{ + private const string WorkerResultPrefix = "BENCHMARK_RESULT:"; + private const int DefaultColdIterations = 5; + private const int DefaultWarmIterations = 30; + private const int DefaultResourceIterations = 100; + private const int WarmupIterations = 5; + + /// + /// Runs the coordinator or one STA worker process. + /// + /// The benchmark mode and optional iteration counts. + /// Zero when all measurements complete successfully. + [STAThread] + private static int Main(string[] args) + { + try + { + if (args.Length > 0 && args[0] == "--worker") + { + RunWorker(args); + return 0; + } + + RunBenchmarks(args); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine(exception); + return 1; + } + } + + private static void RunBenchmarks(string[] args) + { + int coldIterations = GetIterationCount(args, "--cold-iterations", DefaultColdIterations); + int warmIterations = GetIterationCount(args, "--warm-iterations", DefaultWarmIterations); + int resourceIterations = GetIterationCount(args, "--resource-iterations", DefaultResourceIterations); + Scenario[] scenarios = Enum.GetValues(); + + Console.WriteLine("WinForms Application Builder lifecycle benchmark"); + Console.WriteLine($"Cold process launches per scenario: {coldIterations}"); + Console.WriteLine($"Warm iterations per scenario: {warmIterations}"); + Console.WriteLine($"Resource-stability cycles per scenario: {resourceIterations}"); + Console.WriteLine(); + + foreach (Scenario scenario in scenarios) + { + Console.WriteLine($"Scenario: {scenario}"); + + List coldProcessMilliseconds = []; + for (int iteration = 0; iteration < coldIterations; iteration++) + { + WorkerExecution execution = RunWorkerProcess(scenario, WorkerMode.Cold, iterationCount: 1); + coldProcessMilliseconds.Add(execution.ProcessElapsedMilliseconds); + } + + WorkerPayload warmPayload = RunWorkerProcess( + scenario, + WorkerMode.Warm, + iterationCount: warmIterations).Payload; + WorkerPayload resourcePayload = RunWorkerProcess( + scenario, + WorkerMode.Resources, + iterationCount: resourceIterations).Payload; + + PrintTimingResults(coldProcessMilliseconds, warmPayload.Measurements); + PrintResourceResults(resourcePayload, resourceIterations); + Console.WriteLine(); + } + + Console.WriteLine("These comparative measurements are diagnostic, not CI thresholds."); + } + + private static void RunWorker(string[] args) + { + if (args.Length != 4 + || !Enum.TryParse(args[1], ignoreCase: true, out Scenario scenario) + || !Enum.TryParse(args[2], ignoreCase: true, out WorkerMode mode) + || !int.TryParse(args[3], out int iterationCount) + || iterationCount < 1) + { + throw new ArgumentException( + "Worker usage: --worker "); + } + + ConfigureWinForms(); + + WorkerPayload payload = mode switch + { + WorkerMode.Cold => CreatePayload(scenario, [RunOnce(scenario)], []), + WorkerMode.Warm => RunWarmWorker(scenario, iterationCount), + WorkerMode.Resources => RunResourceWorker(scenario, iterationCount), + _ => throw new InvalidOperationException($"Unsupported benchmark mode: {mode}.") + }; + + Console.WriteLine( + $"{WorkerResultPrefix}{JsonSerializer.Serialize(payload, BenchmarkJsonContext.Default.WorkerPayload)}"); + } + + private static WorkerPayload RunWarmWorker(Scenario scenario, int iterationCount) + { + for (int iteration = 0; iteration < WarmupIterations; iteration++) + { + _ = RunOnce(scenario); + } + + RunMeasurement[] measurements = new RunMeasurement[iterationCount]; + for (int iteration = 0; iteration < iterationCount; iteration++) + { + measurements[iteration] = RunOnce(scenario); + } + + return CreatePayload(scenario, measurements, []); + } + + private static WorkerPayload RunResourceWorker(Scenario scenario, int iterationCount) + { + for (int iteration = 0; iteration < WarmupIterations; iteration++) + { + _ = RunOnce(scenario); + } + + List snapshots = [CaptureProcessSnapshot()]; + int cyclesPerCheckpoint = Math.Max(1, (int)Math.Ceiling(iterationCount / 4.0)); + int completedIterations = 0; + + while (completedIterations < iterationCount) + { + int checkpointIterations = Math.Min( + cyclesPerCheckpoint, + iterationCount - completedIterations); + for (int iteration = 0; iteration < checkpointIterations; iteration++) + { + _ = RunOnce(scenario); + } + + completedIterations += checkpointIterations; + snapshots.Add(CaptureProcessSnapshot()); + } + + return CreatePayload(scenario, [], [.. snapshots]); + } + + private static WorkerPayload CreatePayload( + Scenario scenario, + RunMeasurement[] measurements, + ProcessSnapshot[] snapshots) + => new(scenario, measurements, snapshots); + + private static RunMeasurement RunOnce(Scenario scenario) + { + Stopwatch totalTimer = Stopwatch.StartNew(); + long allocatedBytesBefore = GC.GetAllocatedBytesForCurrentThread(); + double shownMilliseconds = double.NaN; + double closedMilliseconds = double.NaN; + + Form form = new() + { + FormBorderStyle = FormBorderStyle.None, + Location = new Point(-32000, -32000), + Opacity = 0, + ShowInTaskbar = false, + Size = new Size(1, 1), + WindowState = FormWindowState.Minimized + }; + + try + { + form.Shown += (_, _) => + { + shownMilliseconds = totalTimer.Elapsed.TotalMilliseconds; + form.BeginInvoke(form.Close); + }; + form.FormClosed += (_, _) => closedMilliseconds = totalTimer.Elapsed.TotalMilliseconds; + + switch (scenario) + { + case Scenario.ApplicationRun: + Application.Run(form); + break; + case Scenario.Builder: + using (WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm(form) + .Build()) + { + application.Run(); + } + + break; + case Scenario.BuilderWithHost: + HostApplicationBuilder hostBuilder = Host.CreateApplicationBuilder(); + IHost host = hostBuilder.Build(); + using (WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm(form) + .UseHost(host) + .Build()) + { + application.Run(); + } + + break; + default: + throw new ArgumentOutOfRangeException(nameof(scenario)); + } + } + finally + { + form.Dispose(); + } + + totalTimer.Stop(); + + return new RunMeasurement( + shownMilliseconds, + totalTimer.Elapsed.TotalMilliseconds - closedMilliseconds, + totalTimer.Elapsed.TotalMilliseconds, + GC.GetAllocatedBytesForCurrentThread() - allocatedBytesBefore); + } + + private static void ConfigureWinForms() + { + Application.SetHighDpiMode(HighDpiMode.SystemAware); + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + } + + private static ProcessSnapshot CaptureProcessSnapshot() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + using Process process = Process.GetCurrentProcess(); + process.Refresh(); + + return new ProcessSnapshot( + process.HandleCount, + process.Threads.Count, + process.PrivateMemorySize64, + GC.GetTotalMemory(forceFullCollection: true)); + } + + private static WorkerExecution RunWorkerProcess( + Scenario scenario, + WorkerMode mode, + int iterationCount) + { + string executablePath = Environment.ProcessPath + ?? throw new InvalidOperationException("The benchmark executable path is unavailable."); + ProcessStartInfo startInfo = new(executablePath) + { + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true + }; + startInfo.ArgumentList.Add("--worker"); + startInfo.ArgumentList.Add(scenario.ToString()); + startInfo.ArgumentList.Add(mode.ToString()); + startInfo.ArgumentList.Add(iterationCount.ToString()); + + using Process process = new() { StartInfo = startInfo }; + Stopwatch processTimer = Stopwatch.StartNew(); + if (!process.Start()) + { + throw new InvalidOperationException($"Could not start benchmark worker for {scenario}."); + } + + Task outputTask = process.StandardOutput.ReadToEndAsync(); + Task errorTask = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + Task.WaitAll(outputTask, errorTask); + processTimer.Stop(); + + string standardOutput = outputTask.Result; + string standardError = errorTask.Result; + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"Benchmark worker for {scenario} failed with exit code {process.ExitCode}." + + Environment.NewLine + + standardOutput + + standardError); + } + + string? resultLine = standardOutput + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .LastOrDefault(line => line.StartsWith(WorkerResultPrefix, StringComparison.Ordinal)); + string json = resultLine + ?? throw new InvalidOperationException( + $"Benchmark worker for {scenario} did not return a result." + + Environment.NewLine + + standardOutput + + standardError); + + WorkerPayload payload = JsonSerializer.Deserialize( + json[WorkerResultPrefix.Length..], + BenchmarkJsonContext.Default.WorkerPayload) + ?? throw new InvalidOperationException( + $"Benchmark worker for {scenario} returned invalid JSON."); + + return new WorkerExecution(payload, processTimer.Elapsed.TotalMilliseconds); + } + + private static int GetIterationCount(string[] args, string option, int defaultValue) + { + int optionIndex = Array.IndexOf(args, option); + if (optionIndex < 0) + { + return defaultValue; + } + + if (optionIndex + 1 >= args.Length + || !int.TryParse(args[optionIndex + 1], out int iterationCount) + || iterationCount < 1) + { + throw new ArgumentException($"{option} requires a positive integer."); + } + + return iterationCount; + } + + private static void PrintTimingResults( + List coldProcessMilliseconds, + RunMeasurement[] warmMeasurements) + { + Console.WriteLine( + $" Cold process launch-to-exit ms: median {Percentile(coldProcessMilliseconds, 0.50):F2}, " + + $"p95 {Percentile(coldProcessMilliseconds, 0.95):F2}"); + Console.WriteLine( + $" Warm scenario-to-form-shown ms: median {Percentile(warmMeasurements.Select(item => item.ShownMilliseconds), 0.50):F2}, " + + $"p95 {Percentile(warmMeasurements.Select(item => item.ShownMilliseconds), 0.95):F2}"); + Console.WriteLine( + $" Warm form-closed-to-dispose ms: median {Percentile(warmMeasurements.Select(item => item.ShutdownMilliseconds), 0.50):F2}, " + + $"p95 {Percentile(warmMeasurements.Select(item => item.ShutdownMilliseconds), 0.95):F2}"); + Console.WriteLine( + $" Warm total lifecycle ms: median {Percentile(warmMeasurements.Select(item => item.TotalMilliseconds), 0.50):F2}, " + + $"p95 {Percentile(warmMeasurements.Select(item => item.TotalMilliseconds), 0.95):F2}"); + Console.WriteLine( + $" UI-thread allocated bytes: median {Percentile(warmMeasurements.Select(item => (double)item.UiThreadAllocatedBytes), 0.50):F0}, " + + $"p95 {Percentile(warmMeasurements.Select(item => (double)item.UiThreadAllocatedBytes), 0.95):F0}"); + } + + private static void PrintResourceResults(WorkerPayload payload, int iterationCount) + { + if (payload.Snapshots.Length < 2) + { + throw new InvalidOperationException("The resource worker did not return interval snapshots."); + } + + int cyclesPerCheckpoint = (int)Math.Ceiling( + iterationCount / (double)(payload.Snapshots.Length - 1)); + for (int snapshotIndex = 1; snapshotIndex < payload.Snapshots.Length; snapshotIndex++) + { + ProcessSnapshot previous = payload.Snapshots[snapshotIndex - 1]; + ProcessSnapshot current = payload.Snapshots[snapshotIndex]; + int cycleCount = Math.Min(snapshotIndex * cyclesPerCheckpoint, iterationCount); + + Console.WriteLine( + $" Resource delta after cycle {cycleCount} (post-GC): " + + $"handles {current.HandleCount - previous.HandleCount:+#;-#;0}, " + + $"threads {current.ThreadCount - previous.ThreadCount:+#;-#;0}, " + + $"private bytes {current.PrivateBytes - previous.PrivateBytes:+#;-#;0}, " + + $"managed live bytes {current.ManagedLiveBytes - previous.ManagedLiveBytes:+#;-#;0}"); + } + } + + private static double Percentile(IEnumerable values, double percentile) + { + double[] sortedValues = [.. values.Order()]; + int index = Math.Clamp((int)Math.Ceiling(percentile * sortedValues.Length) - 1, 0, sortedValues.Length - 1); + + return sortedValues[index]; + } + + /// + /// Identifies the baseline and hosting scenarios. + /// + private enum Scenario + { + ApplicationRun, + Builder, + BuilderWithHost + } + + /// + /// Identifies a benchmark worker's measurement mode. + /// + private enum WorkerMode + { + Cold, + Warm, + Resources + } + + /// + /// Holds timing and UI-thread allocation measurements for one run. + /// + private sealed record RunMeasurement( + double ShownMilliseconds, + double ShutdownMilliseconds, + double TotalMilliseconds, + long UiThreadAllocatedBytes); + + /// + /// Holds process and managed-heap resource counters. + /// + private sealed record ProcessSnapshot( + int HandleCount, + int ThreadCount, + long PrivateBytes, + long ManagedLiveBytes); + + /// + /// Carries a worker's measurements and optional resource snapshots. + /// + private sealed record WorkerPayload( + Scenario Scenario, + RunMeasurement[] Measurements, + ProcessSnapshot[] Snapshots); + + /// + /// Carries worker results and parent-measured process launch duration. + /// + private sealed record WorkerExecution(WorkerPayload Payload, double ProcessElapsedMilliseconds); + + [JsonSerializable(typeof(WorkerPayload))] + private sealed partial class BenchmarkJsonContext : JsonSerializerContext + { + } +} diff --git a/docs/application-builder/benchmarks/README.md b/docs/application-builder/benchmarks/README.md new file mode 100644 index 00000000000..5a1bcc66586 --- /dev/null +++ b/docs/application-builder/benchmarks/README.md @@ -0,0 +1,73 @@ +# WinForms Application Builder lifecycle benchmarks + +This standalone STA-aware harness compares three complete UI-thread lifecycles: + +- Conventional `Application.Run(Form)`. +- `WinFormsApplicationBuilder` without a host. +- `WinFormsApplicationBuilder` with an empty Generic Host. + +Each run uses a small minimized, off-screen form that closes itself after it is +shown. The harness runs each scenario in a separate child process for cold +launch measurements and for independent warm/resource sessions. This prevents +static initialization from one scenario from contaminating the others and +keeps all WinForms work on an STA thread. The benchmark project references the +WinForms implementation in this checkout. + +## Run + +From the repository root in PowerShell: + +```powershell +$env:DOTNET_ROOT = "$PWD\.dotnet" +$env:PATH = "$PWD\.dotnet;$env:PATH" +dotnet run --configuration Release --project docs\application-builder\benchmarks\WinFormsApplicationBuilder.Benchmarks.csproj +``` + +Defaults are five cold process launches, five warmups plus 30 timed warm +cycles, and five warmups plus 100 resource-stability cycles per scenario. +Iteration counts can be changed with `--cold-iterations`, `--warm-iterations`, +and `--resource-iterations`, respectively. For example: + +```powershell +dotnet run --configuration Release --project docs\application-builder\benchmarks\WinFormsApplicationBuilder.Benchmarks.csproj -- --cold-iterations 10 --warm-iterations 100 --resource-iterations 500 +``` + +## Measurements and interpretation + +- **Cold process launch-to-exit** is parent-measured and includes process and + CLR startup, WinForms initialization, form display, and shutdown. It is not + an isolated `Run` call measurement. +- **Warm scenario-to-form-shown** measures scenario construction through the + form's `Shown` event after in-process warmups. +- **Warm form-closed-to-dispose** measures from `FormClosed` through message + loop exit and builder/host disposal. +- **UI-thread allocated bytes** uses + `GC.GetAllocatedBytesForCurrentThread`. It deliberately does not claim to + include allocations on Generic Host worker threads. +- **Resource deltas** report changes in process handle count, thread count, + private bytes, and post-full-GC managed live bytes relative to the preceding + checkpoint, with four checkpoints during the default repeated-cycle run. + One-time runtime, JIT, WinForms, and host caches may account for bounded + initial growth; inspect whether counters continue increasing across later + checkpoints and repeat runs before labeling growth a leak. + +Results are comparative diagnostics, not pass/fail thresholds. No acceptable +overhead budget has been set by #14946. Record the commit, SDK/runtime, OS +build, architecture, power mode, iteration counts, and complete output when +comparing runs. Cold timings and private bytes are particularly sensitive to +machine load and should not be compared across different hardware as if they +were controlled. + +## Decisions and limits + +- A dedicated harness is used instead of BenchmarkDotNet because the measured + unit of work owns the calling STA thread and runs a real WinForms message + loop; each cold-start trial also needs a fresh process. +- The baseline uses the same form and automatic close behavior, while the two + builder scenarios isolate the builder's own cost from adding Generic Host. +- The harness does not register hosted services, open modal dialogs, or + measure application-specific UI construction. +- Resource snapshots are coarse process counters. They can reveal monotonic + growth but do not identify an allocation site or replace a profiler. +- CI execution is intentionally not configured: timing and process-resource + numbers are environment-sensitive and are not stable correctness assertions. diff --git a/docs/application-builder/benchmarks/WinFormsApplicationBuilder.Benchmarks.csproj b/docs/application-builder/benchmarks/WinFormsApplicationBuilder.Benchmarks.csproj new file mode 100644 index 00000000000..761a70dd4a2 --- /dev/null +++ b/docs/application-builder/benchmarks/WinFormsApplicationBuilder.Benchmarks.csproj @@ -0,0 +1,16 @@ + + + + Exe + net11.0-windows7.0 + true + enable + enable + + + + + + + + diff --git a/docs/application-builder/core-contracts.md b/docs/application-builder/core-contracts.md new file mode 100644 index 00000000000..8d98752c0b5 --- /dev/null +++ b/docs/application-builder/core-contracts.md @@ -0,0 +1,72 @@ +# WinForms Application Builder core contracts + +**Status:** Contract prototype for issue [#14942](https://github.com/dotnet/winforms/issues/14942) +**Architecture decisions:** [lifetime architecture](lifetime-architecture.md) +**Parent proposal:** [#14082](https://github.com/dotnet/winforms/issues/14082) + +## Contract boundary + +The prototype places `WinFormsApplicationBuilder`, `WinFormsApplication`, +`WinFormsApplicationLifetime`, and the internal `WinFormsApplicationOptions` +in the `Microsoft.Extensions.WinForms` namespace in `System.Windows.Forms.dll`. +This follows the proposal's single-assembly option. Runtime coordination uses +`Microsoft.Extensions.Hosting.Abstractions` only to accept and coordinate an +existing `IHost`; the application builder does not create a host or add +dependency-injection, configuration, or logging APIs. + +The builder supports selecting a form by type or instance, or selecting a +default or supplied `ApplicationContext`. The last startup-selection call wins. +Generic form selection stores a factory; it does not instantiate a control +during builder creation, `Build`, or option copying. `Build` snapshots the +builder's options so subsequent builder changes do not alter an already-built +application. + +The options type is internal. The prototype does not expose services, +configuration, logging, or a public options pattern. Applications continue to +own their generated `ApplicationConfiguration.Initialize()` call; the builder +does not attempt to reference application-specific generated code. + +## Lifetime contract + +The application exposes one lifetime object with `ApplicationStarted`, +`ApplicationStopping`, and `ApplicationStopped` events. Internal notification +methods are one-shot and retain the required ordering; a stop after failed +startup does not synthesize an `ApplicationStarted` notification. Event-handler +exceptions propagate to the runtime coordinator, which must preserve cleanup +and failure semantics when it is implemented. + +## Deferred to issue #14943 + +The runtime added by #14943 runs on the calling UI thread, installs the +WinForms synchronization context before creating a deferred startup form, and +uses the existing `Application.Run(ApplicationContext)` message loop. A +configured `IHost` is started before the WinForms started notification and is +stopped before an intercepted thread exit is allowed to unwind the loop. The +application owns and disposes a host passed to `UseHost`. Host-originated +stopping notifications are marshalled to the UI thread while the coordinator +is active. + +Application-context exit deferral is an internal hook used to keep the message +pump responsive while asynchronous host stop callbacks finish. Ordinary +WinForms contexts without a configured host retain their existing synchronous +exit behavior. A host stop is terminal: after the host has begun stopping, an +application shutdown request is not canceled by a form-close veto. + +The current bridge accepts an already-created `IHost`; it does not create the +host or expose `IServiceCollection`, configuration, logging, hosted-service +registration, or an options pattern. Runnable C# and Visual Basic examples +using an externally built host are in [samples](samples/README.md). A +standalone lifecycle and resource benchmark harness is documented in +[benchmarks](benchmarks/README.md). + +## Alternatives considered + +- **A separate hosting assembly:** deferred. The proposal recommends the + single-assembly option for the core types, and this prototype has no + independent package dependency that would justify a second assembly. +- **Implementing a message loop or a host adapter here:** rejected. WinForms + already owns message-loop behavior, and implementing runtime coordination + here would overlap #14943. +- **Adding dependency-injection or application-configuration APIs now:** + rejected. Those APIs and application-specific initialization semantics are + outside the minimal contract prototype. diff --git a/docs/application-builder/lifetime-architecture.md b/docs/application-builder/lifetime-architecture.md new file mode 100644 index 00000000000..dad589675aa --- /dev/null +++ b/docs/application-builder/lifetime-architecture.md @@ -0,0 +1,126 @@ +# WinForms Application Builder lifetime architecture + +**Status:** Architecture decision record for issue [#14941](https://github.com/dotnet/winforms/issues/14941)\ +**Parent proposal:** [#14082](https://github.com/dotnet/winforms/issues/14082)\ +**Related proposal:** [#11415](https://github.com/dotnet/winforms/issues/11415) + +This document records the lifetime and hosting decisions that should constrain the later Application Builder implementation. It does not add public APIs or runtime behavior. Contracts, message-loop coordination, tests, samples, and benchmarks remain assigned to their respective child issues. + +## Decision summary + +1. The thread that calls `WinFormsApplication.Run` is the UI thread. The builder does not create a second UI thread. +2. The hosting layer delegates to the existing `Application.Run(Form)` or `Application.Run(ApplicationContext)` implementation. It does not implement another message pump or replace WinForms modal-loop behavior. +3. Host construction (`Build`) is distinct from host startup (`IHost.StartAsync`). The host can be built before the UI synchronization context exists. At run time, establish the WinForms synchronization context and create/obtain the startup UI object before completing host startup and publishing the WinForms `ApplicationStarted` event. This ensures that the WinForms started event follows UI initialization and precedes entry into `Application.Run`. +4. Host-initiated exit must be marshaled to the UI thread. Shutdown coordination must be asynchronous and idempotent; a UI-thread caller must not synchronously wait for host work that needs UI dispatch. +5. Form-close cancellation and `ApplicationContext.ExitThread` are materially different shutdown paths. The latter is synchronous and has no cancellation point. Graceful asynchronous host shutdown for an arbitrary supplied `ApplicationContext` requires an explicit pre-exit coordination mechanism; subscribing to `ThreadExit` or `ApplicationExit` alone is too late to guarantee it. +6. Generic Host lifetime tokens remain the host-facing lifecycle source. WinForms lifetime notifications must be one-shot and ordered, not an independent competing host lifetime. + +## Existing WinForms behavior + +### Thread and message-loop ownership + +`Application.Run()` overloads delegate to `Application.ThreadContext.RunMessageLoop`. `ThreadContext` is thread-static, and the loop is started on the calling thread. The concrete thread contexts use the existing WinForms/component-manager message-pump infrastructure. Modal forms and `DoEvents` use nested loops with different loop reasons; an application builder must not replace these paths with a custom loop or call `DoEvents` as a substitute for the main loop. + +For a main loop, `ThreadContext.RunMessageLoopInner` associates the `ApplicationContext`, makes its `MainForm` visible, installs the synchronization context if needed, and then invokes the existing message loop. The main-loop path rejects a nested main loop. The UI thread is therefore owned by the caller; the hosting layer owns only the decision to enter and leave the normal WinForms run path. + +The standard `[STAThread]` entry point remains the expected way to select the UI thread. A future runtime implementation should validate that `Run` executes on the intended thread and must not silently move a supplied form or context to another thread. Builder creation and `Build` should not create controls or start the message loop. + +### Synchronization context + +`WindowsFormsSynchronizationContext.InstallIfNeeded` is called automatically by `Control` construction when `AutoInstall` is enabled, and again when the first message loop is entered. It does not replace a non-default synchronization context. Installation creates/uses the thread's WinForms marshaling control; `Post` uses `BeginInvoke` and `Send` uses `Invoke`. The context records the prior context for restoration. + +The current main-loop implementation makes `ApplicationContext.MainForm` visible before its message-loop-level synchronization-context installation. Consequently, the hosting path must establish the context before startup-form activation rather than relying only on the installation that occurs inside `Application.Run`. + +`UseStartupForm(Form)` accepts an already-constructed control. In the normal case, WinForms auto-installation occurs during base `Control` construction, before the derived form constructor body, but that is not guaranteed when `AutoInstall` is disabled or another synchronization context is already present. A future implementation must preserve and restore a caller's existing context and must not unconditionally overwrite it. The generic host adapter should use WinForms' existing installation/restoration mechanism through an appropriate in-assembly hook; it should not duplicate the marshaling-control implementation or call the public `Uninstall()` in a way that permanently disables auto-installation. + +### Shutdown and failures + +`Application.Exit` raises closing notifications for open forms and can be canceled by a form. If not canceled, it closes the forms and asks all thread contexts to exit. `Application.ExitThread` exits only the current thread and delegates to its `ApplicationContext` when one exists. `ApplicationContext.ExitThreadCore` raises the synchronous `ThreadExit` event; the default main-form close path reaches it through the context's main-form handle-destroyed notification. `ThreadContext` posts quit and performs thread/context cleanup as its loop unwinds. `ApplicationExit` is raised during thread-context teardown, not as a cancellable pre-exit hook. + +WinForms also has its own UI-thread exception routing. Exceptions from form/message dispatch may be passed to `Application.ThreadException` (or the default WinForms handling policy); they are not automatically equivalent to exceptions thrown by host startup or by the outer `Application.Run` call. The hosting layer must not silently replace existing exception behavior as part of this architecture work. + +## Generic Host reconciliation + +The Generic Host owns service startup and shutdown, not the WinForms message pump. `IHost.StartAsync` runs host startup and hosted-service callbacks and publishes `IHostApplicationLifetime.ApplicationStarted` after successful startup callbacks. `IHost.StopAsync` signals `ApplicationStopping`, stops hosted services in reverse registration order, and publishes `ApplicationStopped` after hosted-service stop callbacks. It then stops `IHostLifetime`; therefore the `ApplicationStopped` token does not mean the outer `StopAsync` call has returned. The stop cancellation token is cooperative; services can observe cancellation and failures can be reported/aggregated. `IHostLifetime` represents host/process start-stop signaling and is not a reason to create a separate WinForms UI thread. + +The terms in the expected WinForms startup sequence must distinguish building the host from starting it: + +1. `CreateBuilder` and `Build` compose the host; they do not start hosted services. +2. `Run` binds execution to the caller's UI thread and establishes the WinForms synchronization context. +3. Startup form or application context creation/selection happens on that UI thread. +4. `IHost.StartAsync` completes host startup; its `ApplicationStarted` token and the WinForms `ApplicationStarted` notification must not precede successful UI initialization. +5. The hosting layer enters `Application.Run` using the selected form or application context. + +This keeps host construction before UI setup while avoiding signaling that the WinForms application is started before its startup UI object exists. If future API constraints force host startup earlier, the relationship between the two `ApplicationStarted` signals must be reconsidered explicitly rather than publishing contradictory lifecycle events. + +## Ownership and lifecycle decisions + +### UI thread + +- The application entry point selects and owns the thread by calling `Run`; it should be an STA thread, as in existing WinForms programs. +- A supplied `Form` or `ApplicationContext` remains associated with the thread on which it was created. `Run` must validate incompatible thread use rather than move the object. +- The hosting layer owns the run operation and shutdown coordination, not thread creation or Win32 message dispatch. +- `Run` is the synchronous boundary for the existing message loop. An async API is not required by this architecture record; adding one requires a separate design for preserving UI-thread affinity and avoiding a pre-loop async deadlock. + +### Message loop + +- Call the existing `Application.Run(Form)` or `Application.Run(ApplicationContext)` exactly once for the main loop. +- Let WinForms own nested modal/modeless message loops, filters, component-manager integration, thread-exception routing, and loop teardown. +- Host requests must be posted to the UI thread before invoking UI shutdown APIs. Do not call `Application.Exit` from an arbitrary worker thread. +- Startup failures before `Application.Run` propagate to the caller after cleanup. Exceptions escaping the run operation also propagate after host/context cleanup; UI-dispatch exceptions retain WinForms' established routing. + +### Startup and lifetime events + +- `ApplicationStarted` is raised once, only after host startup succeeds and startup UI initialization has completed, and immediately before entering the main message loop. +- `ApplicationStopping` is raised once when terminal shutdown coordination begins, before requesting loop exit. +- `ApplicationStopped` is raised once after the message loop has exited and hosted-service shutdown/cleanup has completed. The outer `IHost.StopAsync` may still be completing `IHostLifetime.StopAsync`; cleanup must still run when startup, runtime, shutdown, or cancellation paths fail. +- Repeated or concurrent stop requests converge on the same shutdown operation. They must not raise lifetime notifications more than once. + +The existing Generic Host token semantics should be exposed consistently; the WinForms layer must not create a second independent set of host tokens with different ordering. The implementation must also account for Generic Host's synchronous lifetime-token callbacks: callbacks may request UI work, but must not block waiting for the UI loop to exit. + +## Shutdown direction and constraints + +### Host to WinForms + +A host stop request starts the stopping transition. The application lifetime coordinator posts the loop-exit request to the owning UI thread, then allows the existing WinForms loop to unwind. Host stop callbacks and cleanup must be coordinated so that the host's stopped notification cannot precede loop exit. A call made on the UI thread must not synchronously wait for a continuation that needs that same UI thread. + +`Application.Exit` can be vetoed by `FormClosing`. The eventual implementation must specify whether a host-initiated stop is terminal despite a form veto, or whether the veto aborts host stopping. Generic Host's stopping token is one-shot and cannot be retracted, so treating a veto as a return to a fully running application is not consistent with Generic Host semantics. This policy must be resolved in the runtime issue without silently changing ordinary `Application.Exit` behavior. + +### WinForms to host + +A main-form close can be intercepted while it is still cancellable. The host coordinator can defer final closure, run asynchronous host shutdown without blocking the message loop, and complete closure once shutdown is ready. This path must avoid re-entrant close and coalesce repeated close/exit requests. + +An arbitrary `ApplicationContext.ExitThread()` is not cancellable: `ThreadExit` is a synchronous notification and the WinForms loop is asked to quit after the event returns. `ApplicationExit` is later still. Neither event alone provides a safe place to await asynchronous host shutdown while continuing to pump UI messages. A host-owned/interceptable context or a narrow WinForms pre-exit hook is therefore required if graceful, async host shutdown is guaranteed for every supplied `ApplicationContext`. The runtime issue must choose and test that mechanism; it must not claim that merely subscribing to `ThreadExit` provides the guarantee. + +## Exception and cancellation model + +- Builder validation and `Build` failures are synchronous and do not start a message loop. +- Startup-form or context construction failures occur on the UI thread before the loop starts. They prevent `ApplicationStarted`; cleanup still runs. +- Host `StartAsync` failures/cancellation prevent `ApplicationStarted` and prevent entering `Application.Run`. Any partially started host services are stopped according to Generic Host behavior, and cleanup failures must not hide the original startup failure. +- Exceptions routed by WinForms' UI-thread exception mechanism retain that mechanism. Exceptions escaping the outer run operation are surfaced to the caller after cleanup. +- Host-stop exceptions and cancellation are surfaced after best-effort cleanup. Preserve the primary runtime/startup error and retain shutdown errors as additional failure information. +- A startup or stop `CancellationToken` is cooperative and distinct from a WinForms `FormClosing` veto. A canceled shutdown request must not cause duplicate lifetime events or skip cleanup. Exact public cancellation overloads are deferred to API design. + +## Alternatives considered + +- **Create a dedicated GUI thread from an `IHostedService`: rejected.** This is the approach described by #11415's prototype. It conflicts with conventional WinForms entry-point/thread ownership, complicates supplied-form affinity, and requires cross-thread marshaling between the application and UI lifetimes. +- **Implement a parallel message pump: rejected.** WinForms already provides `Application.Run`, thread contexts, modal-loop handling, component-manager behavior, and teardown. Reimplementing these would duplicate sensitive infrastructure and risk regressions. +- **Use `ApplicationExit` as the graceful-shutdown hook: rejected.** It occurs during teardown and cannot defer loop exit for async host cleanup. +- **Assume `ApplicationContext.ThreadExit` is cancellable: rejected.** The event is synchronous and `ExitThreadCore` has no cancellation result. +- **Replace the current synchronization context unconditionally: rejected.** This would break callers that install a custom context and would diverge from `WindowsFormsSynchronizationContext.InstallIfNeeded` behavior. + +## Scope and follow-up ownership + +This record completes architecture research only. It does not implement builder/lifetime contracts or runtime coordination. + +- **#14942:** prototype the minimal builder, application, options, and lifetime contracts; settle public API boundaries without DI, configuration, or logging. +- **#14943:** implement same-thread startup, synchronization-context timing, existing message-loop ownership, and host/WinForms shutdown coordination. Resolve the cancellable-form-close versus non-cancellable-`ApplicationContext.ExitThread` constraint before claiming complete graceful shutdown. +- **#14944:** add deterministic lifecycle, cancellation, exception, and shutdown tests. +- **#14945:** add samples only; do not add hosted-service infrastructure as part of the samples issue. +- **#14946:** add benchmarks only after the lifecycle implementation is stable. + +## References + +- WinForms: [`Application.cs`](../../src/System.Windows.Forms/System/Windows/Forms/Application.cs), [`Application.ThreadContext.cs`](../../src/System.Windows.Forms/System/Windows/Forms/Application.ThreadContext.cs), [`ApplicationContext.cs`](../../src/System.Windows.Forms/System/Windows/Forms/ApplicationContext.cs), [`WindowsFormsSynchronizationContext.cs`](../../src/System.Windows.Forms/System/Windows/Forms/WindowsFormsSynchronizationContext.cs), [`Control.cs`](../../src/System.Windows.Forms/System/Windows/Forms/Control.cs), [`Application.LightThreadContext.cs`](../../src/System.Windows.Forms/System/Windows/Forms/Application.LightThreadContext.cs), [`Application.ComponentThreadContext.cs`](../../src/System.Windows.Forms/System/Windows/Forms/Application.ComponentThreadContext.cs). +- Generic Host implementation: [`Host.StartAsync`/`Host.StopAsync`](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs), [`IHostApplicationLifetime`](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/IHostApplicationLifetime.cs). +- Earlier proposal: [#11415](https://github.com/dotnet/winforms/issues/11415). diff --git a/docs/application-builder/samples/CSharp/Program.cs b/docs/application-builder/samples/CSharp/Program.cs new file mode 100644 index 00000000000..95b3fb13bc1 --- /dev/null +++ b/docs/application-builder/samples/CSharp/Program.cs @@ -0,0 +1,105 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Drawing; +using System.Windows.Forms; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.WinForms; + +namespace ApplicationBuilderSample.CSharp; + +internal static class Program +{ + [STAThread] + private static void Main(string[] args) + { + Application.SetHighDpiMode(HighDpiMode.SystemAware); + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + + HostApplicationBuilder hostBuilder = Host.CreateApplicationBuilder(args); + hostBuilder.Services.AddHostedService(); + IHost host = hostBuilder.Build(); + + WinFormsApplicationBuilder applicationBuilder = WinFormsApplication.CreateBuilder() + .UseHost(host); + + if (args.Contains("--custom-context", StringComparer.OrdinalIgnoreCase)) + { + applicationBuilder.UseApplicationContext(new MainApplicationContext()); + } + else + { + applicationBuilder.UseStartupForm(); + } + + using WinFormsApplication application = applicationBuilder.Build(); + application.Run(); + } +} + +internal sealed class MainForm : Form +{ + public MainForm() + { + Text = "WinForms Application Builder"; + ClientSize = new Size(520, 170); + + Label description = new() + { + AutoSize = true, + Location = new Point(16, 20), + Text = "A hosted background service writes a heartbeat each second." + }; + + Label shutdownDescription = new() + { + AutoSize = true, + Location = new Point(16, 50), + Text = "Close this window to cancel the service and stop the host gracefully." + }; + + Button closeButton = new() + { + Anchor = AnchorStyles.Bottom | AnchorStyles.Right, + Location = new Point(410, 115), + Text = "Close" + }; + closeButton.Click += (_, _) => Close(); + + Controls.Add(description); + Controls.Add(shutdownDescription); + Controls.Add(closeButton); + } +} + +internal sealed class MainApplicationContext : ApplicationContext +{ + public MainApplicationContext() + : base(new MainForm()) + { + } +} + +internal sealed class HeartbeatService : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using PeriodicTimer timer = new(TimeSpan.FromSeconds(1)); + + try + { + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + Debug.WriteLine($"Background service heartbeat at {DateTimeOffset.Now}."); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + Debug.WriteLine("Background service observed host shutdown."); + } + } +} diff --git a/docs/application-builder/samples/CSharp/WinFormsApplicationBuilder.CSharp.csproj b/docs/application-builder/samples/CSharp/WinFormsApplicationBuilder.CSharp.csproj new file mode 100644 index 00000000000..cc6d99e3082 --- /dev/null +++ b/docs/application-builder/samples/CSharp/WinFormsApplicationBuilder.CSharp.csproj @@ -0,0 +1,16 @@ + + + + WinExe + net11.0-windows7.0 + true + enable + enable + + + + + + + + diff --git a/docs/application-builder/samples/README.md b/docs/application-builder/samples/README.md new file mode 100644 index 00000000000..ae302607fd7 --- /dev/null +++ b/docs/application-builder/samples/README.md @@ -0,0 +1,60 @@ +# WinForms Application Builder samples + +These small applications demonstrate the current in-repository Application +Builder API together with Generic Host. Each sample includes a default +startup-form path, an alternate custom `ApplicationContext` path, and a +cancellable `BackgroundService`. Closing the form asks the runtime to stop the +host; the host's stopping token cancels the background service before the +message loop unwinds. + +The sample projects reference `src\System.Windows.Forms\System.Windows.Forms.csproj` +so they can be built against the implementation in this checkout. They target +the repository's current .NET preview and are examples, not installed SDK +templates. + +## Run + +Build the samples from the repository root: + +```powershell +dotnet build docs\application-builder\samples\CSharp\WinFormsApplicationBuilder.CSharp.csproj +dotnet build docs\application-builder\samples\VisualBasic\WinFormsApplicationBuilder.VisualBasic.vbproj +``` + +Run either executable without arguments to select a startup form by type. +Pass `--custom-context` to select the sample's `ApplicationContext` instead. +The background service writes heartbeat messages to the debugger output; +closing the form requests coordinated host shutdown. + +## Exploratory validation matrix + +The matrix records manual validation dimensions requested by #14945. Both +languages and startup modes were launched in the available local console +session. Other environment-specific GUI runs remain unverified and must not be +inferred from successful builds. + +| Environment | Architecture | Theme | Hardware | Result | +|---|---|---|---|---| +| Local Console session | x64 | Light | Current machine | C# and VB; both startup modes started and shut down with exit code 0 | +| Local Console session | x64 | Dark | Current machine | Not run | +| Terminal Services / Remote Desktop session | x64 | Light and Dark | Remote session | Not run | +| Windows on ARM | ARM64 | Light and Dark | ARM device | Not run | +| Local or remote Windows session | x64 / ARM64 | Light and Dark | Slow or constrained hardware | Not run | + +For each manual run, verify both startup modes, confirm the heartbeat ceases +after closing the window, check that the process exits, and note whether the +window remains responsive during host shutdown. Record OS build, architecture, +session type, theme, hardware, and observed result when updating this matrix. + +## Decisions and scope + +- The samples use the existing `IHost` integration and do not add a + hosted-service abstraction to WinForms or change the runtime API. +- The default startup-form mode is the normal path. The `--custom-context` + switch keeps the context example runnable without maintaining a second + application entry point. +- Cancellation is demonstrated through the `BackgroundService` stopping token; + stopping remains coordinated by the runtime rather than by a second message + loop or a synchronous wait on the UI thread. +- Terminal Services, Windows on ARM, theme, and slow-hardware validation + require environments not available to the local build and unit-test run. diff --git a/docs/application-builder/samples/VisualBasic/Program.vb b/docs/application-builder/samples/VisualBasic/Program.vb new file mode 100644 index 00000000000..5d5b5a81aef --- /dev/null +++ b/docs/application-builder/samples/VisualBasic/Program.vb @@ -0,0 +1,92 @@ +' Licensed to the .NET Foundation under one or more agreements. +' The .NET Foundation licenses this file to you under the MIT license. + +Imports Microsoft.Extensions.DependencyInjection +Imports Microsoft.Extensions.Hosting +Imports Microsoft.Extensions.WinForms +Imports System.Diagnostics +Imports System.Drawing +Imports System.Threading +Imports System.Windows.Forms + +Friend Module Program + + Friend Sub Main(args As String()) + Application.SetHighDpiMode(HighDpiMode.SystemAware) + Application.EnableVisualStyles() + Application.SetCompatibleTextRenderingDefault(False) + + Dim hostBuilder As HostApplicationBuilder = Host.CreateApplicationBuilder(args) + hostBuilder.Services.AddHostedService(Of HeartbeatService)() + Dim genericHost As IHost = hostBuilder.Build() + + Dim applicationBuilder As WinFormsApplicationBuilder = + WinFormsApplication.CreateBuilder().UseHost(genericHost) + + If args.Contains("--custom-context", StringComparer.OrdinalIgnoreCase) Then + applicationBuilder.UseApplicationContext(New MainApplicationContext()) + Else + applicationBuilder.UseStartupForm(Of MainForm)() + End If + + Using application As WinFormsApplication = applicationBuilder.Build() + application.Run() + End Using + End Sub +End Module + +Friend NotInheritable Class MainForm + Inherits Form + + Public Sub New() + Text = "WinForms Application Builder" + ClientSize = New Size(520, 170) + + Dim description As New Label With { + .AutoSize = True, + .Location = New Point(16, 20), + .Text = "A hosted background service writes a heartbeat each second." + } + + Dim shutdownDescription As New Label With { + .AutoSize = True, + .Location = New Point(16, 50), + .Text = "Close this window to cancel the service and stop the host gracefully." + } + + Dim closeButton As New Button With { + .Anchor = AnchorStyles.Bottom Or AnchorStyles.Right, + .Location = New Point(410, 115), + .Text = "Close" + } + AddHandler closeButton.Click, Sub(sender, e) Close() + + Controls.Add(description) + Controls.Add(shutdownDescription) + Controls.Add(closeButton) + End Sub +End Class + +Friend NotInheritable Class MainApplicationContext + Inherits ApplicationContext + + Public Sub New() + MyBase.New(New MainForm()) + End Sub +End Class + +Friend NotInheritable Class HeartbeatService + Inherits BackgroundService + + Protected Overrides Async Function ExecuteAsync(stoppingToken As CancellationToken) As Task + Using timer As New PeriodicTimer(TimeSpan.FromSeconds(1)) + Try + While Await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(False) + Debug.WriteLine($"Background service heartbeat at {DateTimeOffset.Now}.") + End While + Catch ex As OperationCanceledException When stoppingToken.IsCancellationRequested + Debug.WriteLine("Background service observed host shutdown.") + End Try + End Using + End Function +End Class diff --git a/docs/application-builder/samples/VisualBasic/WinFormsApplicationBuilder.VisualBasic.vbproj b/docs/application-builder/samples/VisualBasic/WinFormsApplicationBuilder.VisualBasic.vbproj new file mode 100644 index 00000000000..48a24fa9014 --- /dev/null +++ b/docs/application-builder/samples/VisualBasic/WinFormsApplicationBuilder.VisualBasic.vbproj @@ -0,0 +1,20 @@ + + + + WinExe + net11.0-windows7.0 + true + latest + ApplicationBuilderSample.VisualBasic + ApplicationBuilderSample.VisualBasic.Program + On + On + On + + + + + + + + diff --git a/src/System.Windows.Forms/Microsoft/Extensions/WinForms/WinFormsApplication.cs b/src/System.Windows.Forms/Microsoft/Extensions/WinForms/WinFormsApplication.cs new file mode 100644 index 00000000000..a62840634f2 --- /dev/null +++ b/src/System.Windows.Forms/Microsoft/Extensions/WinForms/WinFormsApplication.cs @@ -0,0 +1,585 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.ExceptionServices; +using System.Windows.Forms; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Extensions.WinForms; + +/// +/// Represents a configured Windows Forms application. +/// +/// +/// +/// The thread calling owns the UI thread and message loop. +/// The application starts and stops an optional Generic Host on that thread's +/// lifecycle, but keeps asynchronous host shutdown from blocking the message +/// pump. +/// +/// +public sealed class WinFormsApplication : IDisposable +{ + private readonly Lock _stopLock = new(); + private readonly TaskCompletionSource _hostStoppedSource = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private WinFormsApplicationOptions? _options; + private IHost? _host; + private IHostApplicationLifetime? _hostLifetime; + private ApplicationContext? _applicationContext; + private Control? _marshallingControl; + private Thread? _uiThread; + private Func? _previousExitThreadHandler; + private CancellationTokenRegistration _applicationStoppingRegistration; + private CancellationTokenRegistration _applicationStoppedRegistration; + private Task? _hostStopTask; + private Exception? _runtimeException; + private int _runState; + private int _messageLoopEntered; + private int _allowThreadExit; + private int _hostStopRequestedByApplication; + private int _hostStartAttempted; + private int _hostStopped; + private int _disposeRequested; + + internal WinFormsApplication(WinFormsApplicationOptions options) + { + _options = options; + _host = options.Host; + } + + /// + /// Creates a builder for a Windows Forms application. + /// + /// A new application builder. + public static WinFormsApplicationBuilder CreateBuilder() + => WinFormsApplicationBuilder.CreateBuilder(); + + /// + /// Gets the lifetime notifications for this application. + /// + public WinFormsApplicationLifetime Lifetime { get; } = new(); + + /// + /// Starts the configured host and runs the Windows Forms message loop. + /// + /// + /// No startup form or application context was configured, or this + /// application has already been run. + /// + public void Run() + { + WinFormsApplicationOptions options = Options; + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeRequested) != 0, this); + + if (Interlocked.CompareExchange(ref _runState, 1, 0) != 0) + { + throw new InvalidOperationException("A WinForms application can only be run once."); + } + + if (_hostStopTask is not null) + { + _runState = 2; + throw new InvalidOperationException("A stopped WinForms application cannot be run."); + } + + if (Thread.CurrentThread.GetApartmentState() != ApartmentState.STA) + { + _runState = 2; + throw new InvalidOperationException("The WinForms application must run on an STA thread."); + } + + if (options.StartupObjectThread is not null + && !ReferenceEquals(options.StartupObjectThread, Thread.CurrentThread)) + { + _runState = 2; + throw new InvalidOperationException( + "A supplied startup form or application context must be used on its creating thread."); + } + + bool autoInstall = WindowsFormsSynchronizationContext.AutoInstall; + SynchronizationContext? originalSynchronizationContext = SynchronizationContext.Current; + bool installedSynchronizationContext = false; + Exception? failure = null; + ApplicationContext? applicationContext = null; + + _uiThread = Thread.CurrentThread; + + try + { + WindowsFormsSynchronizationContext.AutoInstall = true; + WindowsFormsSynchronizationContext.InstallIfNeeded(); + installedSynchronizationContext = + originalSynchronizationContext is not WindowsFormsSynchronizationContext + && SynchronizationContext.Current is WindowsFormsSynchronizationContext; + _marshallingControl = Application.ThreadContext.FromCurrent().MarshallingControl; + + applicationContext = CreateApplicationContext(options); + _applicationContext = applicationContext; + _previousExitThreadHandler = applicationContext.ExitThreadHandler; + applicationContext.ExitThreadHandler = OnExitThreadRequested; + + InitializeHostLifetime(); + StartHost(); + Lifetime.NotifyApplicationStarted(); + + bool enterMessageLoop; + lock (_stopLock) + { + enterMessageLoop = Volatile.Read(ref _allowThreadExit) == 0; + if (enterMessageLoop) + { + Volatile.Write(ref _messageLoopEntered, 1); + } + } + + if (enterMessageLoop) + { + Application.Run(applicationContext); + } + } + catch (Exception exception) + { + failure = exception; + } + finally + { + if (applicationContext is not null) + { + applicationContext.ExitThreadHandler = _previousExitThreadHandler; + _applicationContext = null; + + try + { + applicationContext.Dispose(); + } + catch (Exception exception) + { + failure = CombineFailures(failure, exception); + } + } + + if (Volatile.Read(ref _hostStartAttempted) != 0 + && Volatile.Read(ref _hostStopped) == 0) + { + try + { + StopHostAfterUnexpectedLoopExit(); + } + catch (Exception exception) + { + failure = CombineFailures(failure, exception); + } + } + + try + { + Lifetime.NotifyApplicationStopped(); + } + catch (Exception exception) + { + failure = CombineFailures(failure, exception); + } + + _applicationStoppingRegistration.Dispose(); + _applicationStoppedRegistration.Dispose(); + + if (installedSynchronizationContext) + { + WindowsFormsSynchronizationContext.Uninstall(turnOffAutoInstall: false); + } + + if (!ReferenceEquals(SynchronizationContext.Current, originalSynchronizationContext)) + { + SynchronizationContext.SetSynchronizationContext(originalSynchronizationContext); + } + + WindowsFormsSynchronizationContext.AutoInstall = autoInstall; + Volatile.Write(ref _runState, 2); + + failure = CombineFailures(failure, Interlocked.Exchange(ref _runtimeException, null)); + + if (Volatile.Read(ref _disposeRequested) != 0) + { + try + { + DisposeHost(); + } + catch (Exception exception) + { + failure = CombineFailures(failure, exception); + } + finally + { + Interlocked.Exchange(ref _options, null); + } + } + } + + if (failure is not null) + { + ExceptionDispatchInfo.Capture(failure).Throw(); + } + } + + /// + /// Stops the associated host and exits the application's message loop. + /// + /// + /// A token that can cancel the host's cooperative shutdown operation. + /// + /// A task that completes after the host stop request completes. + public Task StopAsync(CancellationToken cancellationToken = default) + { + if (Volatile.Read(ref _runState) == 2) + { + return Task.CompletedTask; + } + + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeRequested) != 0, this); + NotifyApplicationStopping(); + + Task stopTask = EnsureHostStopStarted(cancellationToken); + + return cancellationToken.CanBeCanceled + ? stopTask.WaitAsync(cancellationToken) + : stopTask; + } + + /// + /// Stops the application if it is running and releases the associated host. + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposeRequested, 1) != 0) + { + return; + } + + if (Volatile.Read(ref _runState) == 1) + { + NotifyApplicationStopping(); + _ = EnsureHostStopStarted(CancellationToken.None); + return; + } + + try + { + DisposeHost(); + } + finally + { + Interlocked.Exchange(ref _options, null); + } + } + + internal WinFormsApplicationOptions Options + => _options ?? throw new ObjectDisposedException(nameof(WinFormsApplication)); + + private static ApplicationContext CreateApplicationContext(WinFormsApplicationOptions options) + { + if (options.StartupFormFactory is not null) + { + return new ApplicationContext(options.StartupFormFactory()); + } + + if (options.StartupForm is not null) + { + return new ApplicationContext(options.StartupForm); + } + + if (options.ApplicationContextFactory is not null) + { + return options.ApplicationContextFactory() + ?? throw new InvalidOperationException("The application context factory returned null."); + } + + return options.ApplicationContext + ?? throw new InvalidOperationException( + "Configure a startup form or application context before running the application."); + } + + private void InitializeHostLifetime() + { + if (_host is null) + { + return; + } + + _hostLifetime = _host.Services.GetService(typeof(IHostApplicationLifetime)) + as IHostApplicationLifetime; + + if (_hostLifetime is null) + { + return; + } + + _applicationStoppingRegistration = _hostLifetime.ApplicationStopping.Register( + static state => ((WinFormsApplication)state!).OnHostStopping(), + this); + _applicationStoppedRegistration = _hostLifetime.ApplicationStopped.Register( + static state => ((WinFormsApplication)state!).OnHostStopped(), + this); + + if (_hostLifetime.ApplicationStopped.IsCancellationRequested) + { + Volatile.Write(ref _hostStopped, 1); + _hostStoppedSource.TrySetResult(); + } + } + + private void StartHost() + { + if (_host is null) + { + return; + } + + if (_hostLifetime?.ApplicationStopped.IsCancellationRequested == true + || _hostLifetime?.ApplicationStopping.IsCancellationRequested == true) + { + throw new InvalidOperationException("The configured Generic Host is already stopping or has stopped."); + } + + Volatile.Write(ref _hostStartAttempted, 1); + + if (_hostLifetime?.ApplicationStarted.IsCancellationRequested != true) + { + Task.Run(() => _host.StartAsync()).GetAwaiter().GetResult(); + } + } + + private bool OnExitThreadRequested(ApplicationContext context) + { + if (_previousExitThreadHandler is not null + && !_previousExitThreadHandler(context)) + { + return false; + } + + if (Volatile.Read(ref _allowThreadExit) != 0) + { + return true; + } + + NotifyApplicationStopping(); + + if (_host is null) + { + AllowThreadExit(); + return true; + } + + if (Volatile.Read(ref _hostStopped) != 0 + || _hostLifetime?.ApplicationStopped.IsCancellationRequested == true) + { + AllowThreadExit(); + return true; + } + + if (_hostLifetime?.ApplicationStopping.IsCancellationRequested != true) + { + _ = EnsureHostStopStarted(CancellationToken.None); + } + + return false; + } + + private void OnHostStopping() + { + if (ReferenceEquals(Thread.CurrentThread, _uiThread) || _marshallingControl is null) + { + NotifyApplicationStopping(); + return; + } + + try + { + _marshallingControl.BeginInvoke((Action)NotifyApplicationStopping); + } + catch (Exception exception) + { + RecordRuntimeException(exception); + NotifyApplicationStopping(); + } + } + + private void OnHostStopped() + { + lock (_stopLock) + { + Volatile.Write(ref _hostStopped, 1); + _hostStoppedSource.TrySetResult(); + } + + if (Volatile.Read(ref _hostStopRequestedByApplication) == 0) + { + AllowThreadExit(); + RequestThreadExit(); + } + } + + private void NotifyApplicationStopping() + { + try + { + Lifetime.NotifyApplicationStopping(); + } + catch (Exception exception) + { + RecordRuntimeException(exception); + } + } + + private Task EnsureHostStopStarted(CancellationToken cancellationToken) + { + lock (_stopLock) + { + if (_hostStopTask is not null) + { + return _hostStopTask; + } + + if (_host is null) + { + Volatile.Write(ref _hostStopped, 1); + Volatile.Write(ref _allowThreadExit, 1); + _hostStoppedSource.TrySetResult(); + _hostStopTask = Task.CompletedTask; + RequestThreadExit(); + return _hostStopTask; + } + + if (_hostLifetime?.ApplicationStopping.IsCancellationRequested == true) + { + _hostStopTask = _hostStoppedSource.Task; + return _hostStopTask; + } + + Volatile.Write(ref _hostStopRequestedByApplication, 1); + _hostStopTask = Task.Run( + () => StopHostAsync(cancellationToken), + CancellationToken.None); + return _hostStopTask; + } + } + + private async Task StopHostAsync(CancellationToken cancellationToken) + { + Exception? failure = null; + + try + { + if (_host is not null) + { + await _host.StopAsync(cancellationToken).ConfigureAwait(false); + } + } + catch (Exception exception) + { + failure = exception; + RecordRuntimeException(exception); + } + finally + { + lock (_stopLock) + { + Volatile.Write(ref _hostStopped, 1); + _hostStoppedSource.TrySetResult(); + } + + AllowThreadExit(); + RequestThreadExit(); + } + + if (failure is not null) + { + ExceptionDispatchInfo.Capture(failure).Throw(); + } + } + + private void AllowThreadExit() + { + lock (_stopLock) + { + Volatile.Write(ref _allowThreadExit, 1); + } + } + + private void StopHostAfterUnexpectedLoopExit() + { + EnsureHostStopStarted(CancellationToken.None).GetAwaiter().GetResult(); + } + + private void RequestThreadExit() + { + ApplicationContext? context = _applicationContext; + + if (Volatile.Read(ref _messageLoopEntered) == 0) + { + return; + } + + if (context is null) + { + if (Volatile.Read(ref _runState) != 1) + { + try + { + Lifetime.NotifyApplicationStopped(); + } + catch (Exception exception) + { + RecordRuntimeException(exception); + } + } + + return; + } + + try + { + if (ReferenceEquals(Thread.CurrentThread, _uiThread)) + { + context.ExitThread(); + } + else + { + _marshallingControl!.BeginInvoke(context.ExitThread); + } + } + catch (Exception exception) + { + RecordRuntimeException(exception); + } + } + + private void RecordRuntimeException(Exception exception) + { + lock (_stopLock) + { + _runtimeException = CombineFailures(_runtimeException, exception); + } + } + + private static Exception? CombineFailures(Exception? first, Exception? second) + { + if (first is null) + { + return second; + } + + if (second is null || ReferenceEquals(first, second)) + { + return first; + } + + return new AggregateException(first, second); + } + + private void DisposeHost() + { + IHost? host = Interlocked.Exchange(ref _host, null); + host?.Dispose(); + } +} diff --git a/src/System.Windows.Forms/Microsoft/Extensions/WinForms/WinFormsApplicationBuilder.cs b/src/System.Windows.Forms/Microsoft/Extensions/WinForms/WinFormsApplicationBuilder.cs new file mode 100644 index 00000000000..7985c712d42 --- /dev/null +++ b/src/System.Windows.Forms/Microsoft/Extensions/WinForms/WinFormsApplicationBuilder.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Windows.Forms; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Extensions.WinForms; + +/// +/// A builder for a Windows Forms application. +/// +/// +/// +/// The builder records the startup UI object without creating it. The +/// application runtime is responsible for activation on the UI thread. +/// +/// +/// Calling a startup selection method replaces any selection previously +/// made on this builder. +/// +/// +public sealed class WinFormsApplicationBuilder +{ + private readonly WinFormsApplicationOptions _options = new(); + + internal WinFormsApplicationBuilder() + { + } + + /// + /// Creates a builder for a Windows Forms application. + /// + /// A new application builder. + public static WinFormsApplicationBuilder CreateBuilder() + => new(); + + /// + /// Selects a startup form to be created by the application runtime. + /// + /// The type of the startup form. + /// This builder. + public WinFormsApplicationBuilder UseStartupForm() + where TForm : Form, new() + { + _options.StartupFormFactory = static () => new TForm(); + _options.StartupForm = null; + _options.ApplicationContextFactory = null; + _options.ApplicationContext = null; + _options.StartupObjectThread = null; + + return this; + } + + /// + /// Selects an existing form as the startup form. + /// + /// The startup form. + /// This builder. + public WinFormsApplicationBuilder UseStartupForm(Form startupForm) + { + ArgumentNullException.ThrowIfNull(startupForm); + + _options.StartupFormFactory = null; + _options.StartupForm = startupForm; + _options.ApplicationContextFactory = null; + _options.ApplicationContext = null; + _options.StartupObjectThread = Thread.CurrentThread; + + return this; + } + + /// + /// Selects a default for the application. + /// + /// This builder. + public WinFormsApplicationBuilder UseApplicationContext() + { + _options.StartupFormFactory = null; + _options.StartupForm = null; + _options.ApplicationContextFactory = static () => new(); + _options.ApplicationContext = null; + _options.StartupObjectThread = null; + + return this; + } + + /// + /// Selects an existing application context for the application. + /// + /// The application context. + /// This builder. + public WinFormsApplicationBuilder UseApplicationContext(ApplicationContext applicationContext) + { + ArgumentNullException.ThrowIfNull(applicationContext); + + _options.StartupFormFactory = null; + _options.StartupForm = null; + _options.ApplicationContextFactory = null; + _options.ApplicationContext = applicationContext; + _options.StartupObjectThread = Thread.CurrentThread; + + return this; + } + + /// + /// Associates a Generic Host with the application. + /// + /// The host to start and stop with the application. + /// This builder. + /// + /// + /// The application takes ownership of the host and disposes it when the + /// application is disposed. + /// + /// + public WinFormsApplicationBuilder UseHost(IHost host) + { + ArgumentNullException.ThrowIfNull(host); + + _options.Host = host; + + return this; + } + + /// + /// Builds a Windows Forms application from the builder's current options. + /// + /// The configured application. + public WinFormsApplication Build() + => new(_options.Clone()); + + internal WinFormsApplicationOptions Options => _options; +} diff --git a/src/System.Windows.Forms/Microsoft/Extensions/WinForms/WinFormsApplicationLifetime.cs b/src/System.Windows.Forms/Microsoft/Extensions/WinForms/WinFormsApplicationLifetime.cs new file mode 100644 index 00000000000..0bb75312e14 --- /dev/null +++ b/src/System.Windows.Forms/Microsoft/Extensions/WinForms/WinFormsApplicationLifetime.cs @@ -0,0 +1,101 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.WinForms; + +/// +/// Provides lifecycle notifications for a Windows Forms application. +/// +/// +/// +/// Notifications are raised at most once and in startup, stopping, stopped +/// order. If startup fails, the started notification is omitted. +/// +/// +/// Runtime coordination raises these events on the application lifecycle +/// thread. Event-handler exceptions are not suppressed. +/// +/// +public sealed class WinFormsApplicationLifetime +{ + /// + /// Identifies the lifecycle phase used to suppress duplicate notifications. + /// + private enum LifecycleState + { + NotStarted, + Started, + Stopping, + Stopped + } + + private LifecycleState _state; + private readonly Lock _stateLock = new(); + + internal WinFormsApplicationLifetime() + { + } + + /// + /// Occurs after the application has started successfully. + /// + public event EventHandler? ApplicationStarted; + + /// + /// Occurs when application shutdown begins. + /// + public event EventHandler? ApplicationStopping; + + /// + /// Occurs after the application has stopped. + /// + public event EventHandler? ApplicationStopped; + + internal void NotifyApplicationStarted() + { + lock (_stateLock) + { + if (_state != LifecycleState.NotStarted) + { + return; + } + + _state = LifecycleState.Started; + ApplicationStarted?.Invoke(this, EventArgs.Empty); + } + } + + internal void NotifyApplicationStopping() + { + lock (_stateLock) + { + if (_state is LifecycleState.Stopping or LifecycleState.Stopped) + { + return; + } + + _state = LifecycleState.Stopping; + ApplicationStopping?.Invoke(this, EventArgs.Empty); + } + } + + internal void NotifyApplicationStopped() + { + lock (_stateLock) + { + if (_state is not LifecycleState.Stopping and not LifecycleState.Stopped) + { + _state = LifecycleState.Stopping; + ApplicationStopping?.Invoke(this, EventArgs.Empty); + } + + if (_state == LifecycleState.Stopped) + { + return; + } + + _state = LifecycleState.Stopped; + ApplicationStopped?.Invoke(this, EventArgs.Empty); + } + } +} diff --git a/src/System.Windows.Forms/Microsoft/Extensions/WinForms/WinFormsApplicationOptions.cs b/src/System.Windows.Forms/Microsoft/Extensions/WinForms/WinFormsApplicationOptions.cs new file mode 100644 index 00000000000..e704ad16133 --- /dev/null +++ b/src/System.Windows.Forms/Microsoft/Extensions/WinForms/WinFormsApplicationOptions.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Windows.Forms; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Extensions.WinForms; + +/// +/// Stores the options used to build a Windows Forms application. +/// +internal sealed class WinFormsApplicationOptions +{ + /// + /// Gets or sets the factory for creating the startup form. + /// + internal Func
? StartupFormFactory { get; set; } + + /// + /// Gets or sets the existing startup form. + /// + internal Form? StartupForm { get; set; } + + /// + /// Gets or sets the factory for creating the application context. + /// + internal Func? ApplicationContextFactory { get; set; } + + /// + /// Gets or sets the existing application context. + /// + internal ApplicationContext? ApplicationContext { get; set; } + + /// + /// Gets or sets the thread that configured a caller-supplied startup object. + /// + internal Thread? StartupObjectThread { get; set; } + + /// + /// Gets or sets the generic host coordinated by the application. + /// + internal IHost? Host { get; set; } + + /// + /// Creates a copy of these options. + /// + /// A new options instance with the same configured startup target. + internal WinFormsApplicationOptions Clone() + => new() + { + StartupFormFactory = StartupFormFactory, + StartupForm = StartupForm, + ApplicationContextFactory = ApplicationContextFactory, + ApplicationContext = ApplicationContext, + StartupObjectThread = StartupObjectThread, + Host = Host + }; +} diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index 34f72136361..d88718e3d26 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -1,4 +1,22 @@ #nullable enable +Microsoft.Extensions.WinForms.WinFormsApplication +Microsoft.Extensions.WinForms.WinFormsApplication.Dispose() -> void +Microsoft.Extensions.WinForms.WinFormsApplication.Lifetime.get -> Microsoft.Extensions.WinForms.WinFormsApplicationLifetime! +Microsoft.Extensions.WinForms.WinFormsApplication.Run() -> void +Microsoft.Extensions.WinForms.WinFormsApplication.StopAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static Microsoft.Extensions.WinForms.WinFormsApplication.CreateBuilder() -> Microsoft.Extensions.WinForms.WinFormsApplicationBuilder! +Microsoft.Extensions.WinForms.WinFormsApplicationBuilder +Microsoft.Extensions.WinForms.WinFormsApplicationBuilder.Build() -> Microsoft.Extensions.WinForms.WinFormsApplication! +static Microsoft.Extensions.WinForms.WinFormsApplicationBuilder.CreateBuilder() -> Microsoft.Extensions.WinForms.WinFormsApplicationBuilder! +Microsoft.Extensions.WinForms.WinFormsApplicationBuilder.UseApplicationContext() -> Microsoft.Extensions.WinForms.WinFormsApplicationBuilder! +Microsoft.Extensions.WinForms.WinFormsApplicationBuilder.UseApplicationContext(System.Windows.Forms.ApplicationContext! applicationContext) -> Microsoft.Extensions.WinForms.WinFormsApplicationBuilder! +Microsoft.Extensions.WinForms.WinFormsApplicationBuilder.UseStartupForm() -> Microsoft.Extensions.WinForms.WinFormsApplicationBuilder! +Microsoft.Extensions.WinForms.WinFormsApplicationBuilder.UseStartupForm(System.Windows.Forms.Form! startupForm) -> Microsoft.Extensions.WinForms.WinFormsApplicationBuilder! +Microsoft.Extensions.WinForms.WinFormsApplicationBuilder.UseHost(Microsoft.Extensions.Hosting.IHost! host) -> Microsoft.Extensions.WinForms.WinFormsApplicationBuilder! +Microsoft.Extensions.WinForms.WinFormsApplicationLifetime +Microsoft.Extensions.WinForms.WinFormsApplicationLifetime.ApplicationStarted -> System.EventHandler? +Microsoft.Extensions.WinForms.WinFormsApplicationLifetime.ApplicationStopped -> System.EventHandler? +Microsoft.Extensions.WinForms.WinFormsApplicationLifetime.ApplicationStopping -> System.EventHandler? override System.Windows.Forms.GroupBox.OnPaintBackground(System.Windows.Forms.PaintEventArgs! pevent) -> void static System.Windows.Forms.Application.SystemVisualSettings.get -> System.Windows.Forms.SystemVisualSettings! static System.Windows.Forms.Application.SystemVisualSettingsChanged -> System.Windows.Forms.SystemVisualSettingsChangedEventHandler? diff --git a/src/System.Windows.Forms/System.Windows.Forms.csproj b/src/System.Windows.Forms/System.Windows.Forms.csproj index ff151e41636..3ee6868e8d0 100644 --- a/src/System.Windows.Forms/System.Windows.Forms.csproj +++ b/src/System.Windows.Forms/System.Windows.Forms.csproj @@ -38,6 +38,7 @@ + diff --git a/src/System.Windows.Forms/System/Windows/Forms/ApplicationContext.cs b/src/System.Windows.Forms/System/Windows/Forms/ApplicationContext.cs index 31be20c2f0c..bd21507a98c 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/ApplicationContext.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/ApplicationContext.cs @@ -15,6 +15,7 @@ namespace System.Windows.Forms; public class ApplicationContext : IDisposable { private Form? _mainForm; + private Func? _exitThreadHandler; /// /// Creates a new ApplicationContext with no mainForm. @@ -112,10 +113,24 @@ protected virtual void Dispose(bool disposing) /// public void ExitThread() => ExitThreadCore(); + internal Func? ExitThreadHandler + { + get => _exitThreadHandler; + set => _exitThreadHandler = value; + } + /// /// Causes the thread's message loop to be terminated. /// - protected virtual void ExitThreadCore() => ThreadExit?.Invoke(this, EventArgs.Empty); + protected virtual void ExitThreadCore() + { + if (_exitThreadHandler is not null && !_exitThreadHandler(this)) + { + return; + } + + ThreadExit?.Invoke(this, EventArgs.Empty); + } /// /// Called when the mainForm is closed. The default implementation diff --git a/src/test/unit/System.Windows.Forms/Microsoft/Extensions/WinForms/WinFormsApplicationBuilderTests.cs b/src/test/unit/System.Windows.Forms/Microsoft/Extensions/WinForms/WinFormsApplicationBuilderTests.cs new file mode 100644 index 00000000000..90a302016fc --- /dev/null +++ b/src/test/unit/System.Windows.Forms/Microsoft/Extensions/WinForms/WinFormsApplicationBuilderTests.cs @@ -0,0 +1,765 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Runtime.ExceptionServices; + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.WinForms; + +namespace System.Windows.Forms.Tests; + +public class WinFormsApplicationBuilderTests +{ + [Fact] + public void CreateBuilder_ReturnsBuilder() + { + WinFormsApplicationBuilder builder = WinFormsApplicationBuilder.CreateBuilder(); + + Assert.NotNull(builder); + } + + [Fact] + public void ApplicationCreateBuilder_ReturnsBuilder() + { + WinFormsApplicationBuilder builder = WinFormsApplication.CreateBuilder(); + + Assert.NotNull(builder); + } + + [Fact] + public void Build_CapturesOptionsAndCreatesLifetime() + { + WinFormsApplicationBuilder builder = WinFormsApplicationBuilder.CreateBuilder() + .UseStartupForm(); + + using WinFormsApplication application = builder.Build(); + + Assert.NotNull(application.Lifetime); + Assert.NotNull(application.Options.StartupFormFactory); + Assert.Null(application.Options.StartupForm); + Assert.Null(application.Options.ApplicationContextFactory); + Assert.Null(application.Options.ApplicationContext); + } + + [WinFormsFact] + public void UseStartupForm_Generic_DefersFormCreationUntilFactoryIsInvoked() + { + TestForm.s_constructionCount = 0; + WinFormsApplicationBuilder builder = WinFormsApplicationBuilder.CreateBuilder() + .UseStartupForm(); + + using WinFormsApplication application = builder.Build(); + + Assert.Equal(0, TestForm.s_constructionCount); + + using Form form = application.Options.StartupFormFactory!(); + + Assert.IsType(form); + Assert.Equal(1, TestForm.s_constructionCount); + } + + [WinFormsFact] + public void UseStartupForm_Instance_StoresSuppliedForm() + { + using Form form = new(); + WinFormsApplicationBuilder builder = WinFormsApplicationBuilder.CreateBuilder() + .UseStartupForm(form); + + using WinFormsApplication application = builder.Build(); + + Assert.Same(form, application.Options.StartupForm); + Assert.Null(application.Options.StartupFormFactory); + } + + [Fact] + public void UseStartupForm_Instance_ThrowsOnNull() + { + WinFormsApplicationBuilder builder = WinFormsApplicationBuilder.CreateBuilder(); + + Assert.Throws(() => builder.UseStartupForm(null!)); + } + + [Fact] + public void UseApplicationContext_Default_DefersContextCreationUntilFactoryIsInvoked() + { + WinFormsApplicationBuilder builder = WinFormsApplicationBuilder.CreateBuilder() + .UseApplicationContext(); + + using WinFormsApplication application = builder.Build(); + + Assert.NotNull(application.Options.ApplicationContextFactory); + Assert.Null(application.Options.ApplicationContext); + + using ApplicationContext context = application.Options.ApplicationContextFactory!(); + + Assert.IsType(context); + } + + [Fact] + public void UseApplicationContext_Instance_StoresSuppliedContext() + { + using ApplicationContext context = new(); + WinFormsApplicationBuilder builder = WinFormsApplicationBuilder.CreateBuilder() + .UseApplicationContext(context); + + using WinFormsApplication application = builder.Build(); + + Assert.Same(context, application.Options.ApplicationContext); + Assert.Null(application.Options.ApplicationContextFactory); + } + + [Fact] + public void UseApplicationContext_Instance_ThrowsOnNull() + { + WinFormsApplicationBuilder builder = WinFormsApplicationBuilder.CreateBuilder(); + + Assert.Throws(() => builder.UseApplicationContext(null!)); + } + + [WinFormsFact] + public void UseStartupForm_OverridesApplicationContextSelection() + { + using ApplicationContext context = new(); + using Form form = new(); + WinFormsApplicationBuilder builder = WinFormsApplicationBuilder.CreateBuilder() + .UseApplicationContext(context) + .UseStartupForm(form); + + using WinFormsApplication application = builder.Build(); + + Assert.Same(form, application.Options.StartupForm); + Assert.Null(application.Options.ApplicationContext); + Assert.Null(application.Options.ApplicationContextFactory); + } + + [WinFormsFact] + public void UseApplicationContext_OverridesStartupFormSelection() + { + using Form form = new(); + using ApplicationContext context = new(); + WinFormsApplicationBuilder builder = WinFormsApplicationBuilder.CreateBuilder() + .UseStartupForm(form) + .UseApplicationContext(context); + + using WinFormsApplication application = builder.Build(); + + Assert.Null(application.Options.StartupForm); + Assert.Null(application.Options.StartupFormFactory); + Assert.Same(context, application.Options.ApplicationContext); + } + + [Fact] + public void Build_SnapshotsBuilderOptions() + { + WinFormsApplicationBuilder builder = WinFormsApplicationBuilder.CreateBuilder() + .UseStartupForm(); + using WinFormsApplication firstApplication = builder.Build(); + + builder.UseApplicationContext(); + using WinFormsApplication secondApplication = builder.Build(); + + Assert.NotNull(firstApplication.Options.StartupFormFactory); + Assert.Null(firstApplication.Options.ApplicationContextFactory); + Assert.Null(secondApplication.Options.StartupFormFactory); + Assert.NotNull(secondApplication.Options.ApplicationContextFactory); + } + + [Fact] + public void Dispose_ReleasesApplicationOptions() + { + WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm() + .Build(); + + application.Dispose(); + application.Dispose(); + + Assert.Throws(() => + { + _ = application.Options; + }); + } + + [Fact] + public void Lifetime_RaisesEachNotificationOnceInOrder() + { + using WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseApplicationContext() + .Build(); + List events = []; + WinFormsApplicationLifetime lifetime = application.Lifetime; + lifetime.ApplicationStarted += (_, _) => events.Add(nameof(lifetime.ApplicationStarted)); + lifetime.ApplicationStopping += (_, _) => events.Add(nameof(lifetime.ApplicationStopping)); + lifetime.ApplicationStopped += (_, _) => events.Add(nameof(lifetime.ApplicationStopped)); + + lifetime.NotifyApplicationStarted(); + lifetime.NotifyApplicationStarted(); + lifetime.NotifyApplicationStopping(); + lifetime.NotifyApplicationStopping(); + lifetime.NotifyApplicationStopped(); + lifetime.NotifyApplicationStopped(); + + Assert.Equal( + [ + nameof(lifetime.ApplicationStarted), + nameof(lifetime.ApplicationStopping), + nameof(lifetime.ApplicationStopped) + ], + events); + } + + [Fact] + public void Lifetime_StoppingBeforeStarted_OmitsStartedAndRaisesStoppedAfterStopping() + { + using WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseApplicationContext() + .Build(); + List events = []; + WinFormsApplicationLifetime lifetime = application.Lifetime; + lifetime.ApplicationStarted += (_, _) => events.Add(nameof(lifetime.ApplicationStarted)); + lifetime.ApplicationStopping += (_, _) => events.Add(nameof(lifetime.ApplicationStopping)); + lifetime.ApplicationStopped += (_, _) => events.Add(nameof(lifetime.ApplicationStopped)); + + lifetime.NotifyApplicationStopping(); + lifetime.NotifyApplicationStarted(); + lifetime.NotifyApplicationStopped(); + + Assert.Equal( + [ + nameof(lifetime.ApplicationStopping), + nameof(lifetime.ApplicationStopped) + ], + events); + } + + [Fact] + public void Run_StartupFormClose_RaisesLifetimeInOrder() + { + RunOnStaThread(() => + { + List events = []; + using WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm() + .Build(); + application.Lifetime.ApplicationStarted += (_, _) => events.Add("Started"); + application.Lifetime.ApplicationStopping += (_, _) => events.Add("Stopping"); + application.Lifetime.ApplicationStopped += (_, _) => events.Add("Stopped"); + + application.Run(); + + Assert.Equal(["Started", "Stopping", "Stopped"], events); + }); + } + + [Fact] + public void Run_StartsHostBeforeStartedAndStopsHostWhenContextExits() + { + RunOnStaThread(() => + { + List events = []; + TestHost host = new(events); + using Form form = new(); + form.Shown += (_, _) => form.Close(); + using WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm(form) + .UseHost(host) + .Build(); + application.Lifetime.ApplicationStarted += (_, _) => events.Add("ApplicationStarted"); + application.Lifetime.ApplicationStopping += (_, _) => events.Add("ApplicationStopping"); + application.Lifetime.ApplicationStopped += (_, _) => events.Add("ApplicationStopped"); + + application.Run(); + + Assert.Equal( + ["HostStarted", "ApplicationStarted", "ApplicationStopping", "HostStopping", "ApplicationStopped"], + events); + Assert.Equal(1, host.StopCount); + }); + } + + [Fact] + public void StopAsync_StopsHostAndExitsTheMessageLoop() + { + RunOnStaThread(() => + { + TestHost host = new(); + using Form form = new(); + using WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm(form) + .UseHost(host) + .Build(); + form.Shown += (_, _) => _ = application.StopAsync(); + + application.Run(); + + Assert.Equal(1, host.StopCount); + Assert.True(host.Lifetime.ApplicationStopped.IsCancellationRequested); + }); + } + + [Fact] + public void Run_ExternalHostStop_ExitsTheMessageLoop() + { + RunOnStaThread(() => + { + TestHost host = new(); + using Form form = new(); + using WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm(form) + .UseHost(host) + .Build(); + form.Shown += (_, _) => _ = Task.Run(() => host.StopAsync()); + + application.Run(); + + Assert.Equal(1, host.StopCount); + Assert.True(host.Lifetime.ApplicationStopped.IsCancellationRequested); + }); + } + + [Fact] + public void Run_ApplicationContextExit_IsDeferredUntilHostStops() + { + RunOnStaThread(() => + { + TestHost host = new(); + using Form form = new(); + using ApplicationContext context = new(form); + form.Shown += (_, _) => context.ExitThread(); + using WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseApplicationContext(context) + .UseHost(host) + .Build(); + + application.Run(); + + Assert.Equal(1, host.StopCount); + Assert.True(host.Lifetime.ApplicationStopped.IsCancellationRequested); + }); + } + + [Fact] + public void Run_StartupFormFailureRaisesStoppingAndStoppedWithoutStartingHost() + { + RunOnStaThread(() => + { + List events = []; + TestHost host = new(events); + using WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm() + .UseHost(host) + .Build(); + application.Lifetime.ApplicationStarted += (_, _) => events.Add("ApplicationStarted"); + application.Lifetime.ApplicationStopping += (_, _) => events.Add("ApplicationStopping"); + application.Lifetime.ApplicationStopped += (_, _) => events.Add("ApplicationStopped"); + + TargetInvocationException exception = Assert.Throws(application.Run); + + Assert.IsType(exception.InnerException); + Assert.Equal(["ApplicationStopping", "ApplicationStopped"], events); + Assert.Equal(0, host.StartCount); + Assert.Equal(0, host.StopCount); + }); + } + + [Fact] + public void Run_HostStartupFailure_StopsHostAndRaisesStopped() + { + RunOnStaThread(() => + { + List events = []; + TestHost host = new( + events, + startAsync: _ => Task.FromException(new InvalidOperationException("Host startup failed."))); + using WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm() + .UseHost(host) + .Build(); + application.Lifetime.ApplicationStopping += (_, _) => events.Add("ApplicationStopping"); + application.Lifetime.ApplicationStopped += (_, _) => events.Add("ApplicationStopped"); + + InvalidOperationException exception = Assert.Throws(application.Run); + + Assert.Equal("Host startup failed.", exception.Message); + Assert.Equal( + ["HostStarted", "HostStopping", "ApplicationStopping", "ApplicationStopped"], + events); + Assert.Equal(1, host.StartCount); + Assert.Equal(1, host.StopCount); + }); + } + + [Fact] + public void Run_StartedHandlerFailure_StopsHostAndRaisesStopped() + { + RunOnStaThread(() => + { + TestHost host = new(); + using WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm() + .UseHost(host) + .Build(); + List events = []; + application.Lifetime.ApplicationStarted += (_, _) => + throw new InvalidOperationException("Started handler failed."); + application.Lifetime.ApplicationStopping += (_, _) => events.Add("Stopping"); + application.Lifetime.ApplicationStopped += (_, _) => events.Add("Stopped"); + + InvalidOperationException exception = Assert.Throws(application.Run); + + Assert.Equal("Started handler failed.", exception.Message); + Assert.Equal(["Stopping", "Stopped"], events); + Assert.Equal(1, host.StopCount); + }); + } + + [Fact] + public void StopAsync_CanceledHostStop_ExitsLoopAndRaisesStopped() + { + RunOnStaThread(() => + { + using CancellationTokenSource cancellation = new(); + TestHost host = new(stopAsync: token => Task.FromCanceled(token)); + using Form form = new(); + using WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm(form) + .UseHost(host) + .Build(); + List events = []; + application.Lifetime.ApplicationStopping += (_, _) => events.Add("Stopping"); + application.Lifetime.ApplicationStopped += (_, _) => events.Add("Stopped"); + Task? stopTask = null; + form.Shown += (_, _) => + { + cancellation.Cancel(); + stopTask = application.StopAsync(cancellation.Token); + }; + + Assert.ThrowsAny(application.Run); + + Assert.NotNull(stopTask); + Assert.True(stopTask.IsCanceled); + Assert.Equal(["Stopping", "Stopped"], events); + Assert.Equal(1, host.StopCount); + }); + } + + [Fact] + public void StopAsync_HostStopFailure_ExitsLoopAndSurfacesFailureAfterCleanup() + { + RunOnStaThread(() => + { + TestHost host = new( + stopAsync: _ => Task.FromException(new InvalidOperationException("Host shutdown failed."))); + using Form form = new(); + using WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm(form) + .UseHost(host) + .Build(); + List events = []; + application.Lifetime.ApplicationStopping += (_, _) => events.Add("Stopping"); + application.Lifetime.ApplicationStopped += (_, _) => events.Add("Stopped"); + Task? stopTask = null; + form.Shown += (_, _) => stopTask = application.StopAsync(); + + InvalidOperationException exception = Assert.Throws(application.Run); + + Assert.Equal("Host shutdown failed.", exception.Message); + Assert.NotNull(stopTask); + Assert.Throws(() => stopTask.GetAwaiter().GetResult()); + Assert.Equal(["Stopping", "Stopped"], events); + Assert.Equal(1, host.StopCount); + }); + } + + [Fact] + public void StopAsync_RepeatedRequests_UseSingleHostStop() + { + RunOnStaThread(() => + { + TestHost host = new(); + using Form form = new(); + using WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm(form) + .UseHost(host) + .Build(); + Task? firstStop = null; + Task? secondStop = null; + form.Shown += (_, _) => + { + firstStop = application.StopAsync(); + secondStop = application.StopAsync(); + }; + + application.Run(); + + Assert.NotNull(firstStop); + Assert.Same(firstStop, secondStop); + Assert.Equal(1, host.StopCount); + }); + } + + [Fact] + public void StopAsync_IsTerminalWhenFormClosingIsCanceled() + { + RunOnStaThread(() => + { + TestHost host = new(); + using Form form = new(); + form.FormClosing += (_, e) => e.Cancel = true; + using WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm(form) + .UseHost(host) + .Build(); + form.Shown += (_, _) => _ = application.StopAsync(); + + application.Run(); + + Assert.True(form.IsDisposed); + Assert.Equal(1, host.StopCount); + }); + } + + [Fact] + public void Dispose_WhileRunning_StopsAndDisposesHost() + { + RunOnStaThread(() => + { + TestHost host = new(); + using Form form = new(); + WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm(form) + .UseHost(host) + .Build(); + form.Shown += (_, _) => application.Dispose(); + + application.Run(); + + Assert.Equal(1, host.StopCount); + Assert.Equal(1, host.DisposeCount); + application.Dispose(); + }); + } + + [Fact] + public void Run_CanBeRepeatedForDistinctApplicationsOnSameUiThread() + { + RunOnStaThread(() => + { + for (int iteration = 0; iteration < 10; iteration++) + { + TestHost host = new(); + using WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm() + .UseHost(host) + .Build(); + + application.Run(); + + Assert.Equal(1, host.StartCount); + Assert.Equal(1, host.StopCount); + } + }); + } + + [Fact] + public void Run_CannotBeRepeatedForTheSameApplication() + { + RunOnStaThread(() => + { + using WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm() + .Build(); + + application.Run(); + + Assert.Throws(application.Run); + }); + } + + [Fact] + public void Run_InstallsSynchronizationContextBeforeActivatingSuppliedFormAndRestoresSettings() + { + RunOnStaThread(() => + { + bool originalAutoInstall = WindowsFormsSynchronizationContext.AutoInstall; + SynchronizationContext? originalContext = SynchronizationContext.Current; + + try + { + WindowsFormsSynchronizationContext.AutoInstall = false; + using Form form = new(); + Assert.Same(originalContext, SynchronizationContext.Current); + + SynchronizationContext? activeContext = null; + form.Shown += (_, _) => + { + activeContext = SynchronizationContext.Current; + form.Close(); + }; + using WinFormsApplication application = WinFormsApplication.CreateBuilder() + .UseStartupForm(form) + .Build(); + + application.Run(); + + Assert.IsType(activeContext); + Assert.Same(originalContext, SynchronizationContext.Current); + Assert.False(WindowsFormsSynchronizationContext.AutoInstall); + } + finally + { + WindowsFormsSynchronizationContext.AutoInstall = originalAutoInstall; + } + }); + } + + private static void RunOnStaThread(Action action) + { + Exception? failure = null; + Thread thread = new(() => + { + try + { + action(); + } + catch (Exception exception) + { + failure = exception; + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (failure is not null) + { + ExceptionDispatchInfo.Capture(failure).Throw(); + } + } + + private sealed class TestForm : Form + { + internal static int s_constructionCount; + + public TestForm() + { + s_constructionCount++; + } + } + + private sealed class CloseOnShownForm : Form + { + protected override void OnShown(EventArgs e) + { + base.OnShown(e); + Close(); + } + } + + private sealed class ThrowingForm : Form + { + public ThrowingForm() + { + throw new InvalidOperationException("Startup form construction failed."); + } + } + + private sealed class TestHost : IHost + { + private readonly List? _events; + private readonly Func? _startAsync; + private readonly Func? _stopAsync; + + internal TestHost( + List? events = null, + Func? startAsync = null, + Func? stopAsync = null) + { + _events = events; + _startAsync = startAsync; + _stopAsync = stopAsync; + Lifetime = new TestHostApplicationLifetime(); + } + + internal TestHostApplicationLifetime Lifetime { get; } + + internal int StartCount { get; private set; } + + internal int StopCount { get; private set; } + + internal int DisposeCount { get; private set; } + + public IServiceProvider Services => new TestServiceProvider(Lifetime); + + public Task StartAsync(CancellationToken cancellationToken = default) + { + StartCount++; + _events?.Add("HostStarted"); + + if (_startAsync is not null) + { + return _startAsync(cancellationToken); + } + + Lifetime.NotifyStarted(); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken = default) + { + StopCount++; + _events?.Add("HostStopping"); + Lifetime.NotifyStopping(); + + if (_stopAsync is not null) + { + return _stopAsync(cancellationToken); + } + + Lifetime.NotifyStopped(); + + return Task.CompletedTask; + } + + public void Dispose() + { + DisposeCount++; + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } + + private sealed class TestHostApplicationLifetime : IHostApplicationLifetime + { + private readonly CancellationTokenSource _started = new(); + private readonly CancellationTokenSource _stopping = new(); + private readonly CancellationTokenSource _stopped = new(); + + public CancellationToken ApplicationStarted => _started.Token; + + public CancellationToken ApplicationStopping => _stopping.Token; + + public CancellationToken ApplicationStopped => _stopped.Token; + + public void StopApplication() => NotifyStopping(); + + internal void NotifyStarted() => _started.Cancel(); + + internal void NotifyStopping() => _stopping.Cancel(); + + internal void NotifyStopped() => _stopped.Cancel(); + } + + private sealed class TestServiceProvider(IHostApplicationLifetime lifetime) : IServiceProvider + { + public object? GetService(Type serviceType) + => serviceType == typeof(IHostApplicationLifetime) ? lifetime : null; + } +}