diff --git a/.editorconfig b/.editorconfig index fc38dfb4..6835e890 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,6 +18,9 @@ csharp_style_expression_bodied_accessors = true:silent csharp_style_expression_bodied_lambdas = true:silent csharp_style_expression_bodied_local_functions = false:silent +# Tests must exercise the public API rather than friend assemblies. +dotnet_diagnostic.RS0030.severity = error + [*.{cs,vb}] dotnet_style_operator_placement_when_wrapping = beginning_of_line tab_width = 4 diff --git a/BannedSymbols.txt b/BannedSymbols.txt new file mode 100644 index 00000000..73b6fb05 --- /dev/null +++ b/BannedSymbols.txt @@ -0,0 +1 @@ +T:System.Runtime.CompilerServices.InternalsVisibleToAttribute;Do not expose internals to other assemblies. Tests must use the public API. diff --git a/Directory.Build.props b/Directory.Build.props index de6f762f..1e98d7d3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -29,6 +29,11 @@ snupkg + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 5c331d88..1063180c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,7 @@ + @@ -18,7 +19,7 @@ - + diff --git a/README.md b/README.md index c891a007..eea4da0c 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,17 @@ After the 1.0.0 release, this library will follow [Semantic Versioning](https:// ## Samples? See the tests (XAMLTests.Tests) for samples of how tests can be setup. [Material Design In XAML Toolkit](https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit) is also leveraging this library for the purpose of testing its various styles and templates. You can see samples of its tests [here](https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/tree/master/MaterialDesignThemes.UITests). + +## Application cleanup +Dispose each `IApp` after use, preferably with `await using`. Disposal allows up to +10 seconds for the shutdown RPC, then waits up to 10 seconds for process exit. +If the host does not exit, it is killed and given another 10 seconds to terminate. +Asynchronous disposal waits without blocking a thread or polling process state. +Local shutdown timeouts are logged and process cleanup still runs; unexpected +RPC errors remain visible to the caller. + +## Testing policy +Tests must exercise public APIs, not internal implementation details. +`Microsoft.CodeAnalysis.BannedApiAnalyzers` runs in every project, using the +repository-wide `BannedSymbols.txt`. `InternalsVisibleToAttribute` is banned; +violations fail the build with RS0030. diff --git a/XAMLTest.Tests/AppDisposalTests.cs b/XAMLTest.Tests/AppDisposalTests.cs new file mode 100644 index 00000000..fac6d6fd --- /dev/null +++ b/XAMLTest.Tests/AppDisposalTests.cs @@ -0,0 +1,170 @@ +using System.Collections.Concurrent; + +namespace XamlTest.Tests; + +[TestClass] +public class AppDisposalTests +{ + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task AlreadyExitedProcess_DisposesSuccessfully(bool asynchronous) + { + IApp app = await StartApp(); + using Process process = await GetHostProcess(app); + try + { + process.Kill(); + await process.WaitForExitAsync(); + + await Dispose(app, asynchronous); + + Assert.IsTrue(process.HasExited); + } + finally + { + await EnsureProcessExited(process); + } + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task TenConcurrentApps_CanStartAndDisposeRepeatedly(bool asynchronous) + { + for (int batch = 0; batch < 3; batch++) + { + await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => Task.Run(async () => + { + IApp app = await StartApp(); + using Process process = await GetHostProcess(app); + try + { + await Dispose(app, asynchronous); + + Assert.IsTrue(process.HasExited); + } + finally + { + await EnsureProcessExited(process); + } + }))); + } + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task SlowExit_DisposalWaitsForProcess(bool asynchronous) + { + ConcurrentQueue messages = new(); + IApp app = await StartApp(messages.Enqueue); + using Process process = await GetHostProcess(app); + try + { + static void DelayExit(Application application) + { + application.Exit += (_, _) => Thread.Sleep(TimeSpan.FromSeconds(1.5)); + } + await app.RemoteExecute(DelayExit); + + Stopwatch stopwatch = Stopwatch.StartNew(); + await Dispose(app, asynchronous); + + Assert.IsTrue(process.HasExited); + Assert.IsTrue(stopwatch.Elapsed >= TimeSpan.FromSeconds(1.5)); + Assert.IsFalse(messages.Any(x => x.Contains("Invoking kill"))); + } + finally + { + await EnsureProcessExited(process); + } + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task UnresponsiveExit_IsKilledAndReaped(bool asynchronous) + { + ConcurrentQueue messages = new(); + IApp app = await StartApp(messages.Enqueue); + using Process process = await GetHostProcess(app); + try + { + static void BlockExit(Application application) + { + application.Exit += (_, _) => Thread.Sleep(Timeout.Infinite); + } + await app.RemoteExecute(BlockExit); + + Task disposal = Dispose(app, asynchronous); + if (asynchronous) + { + Assert.IsFalse(disposal.IsCompleted, "Async disposal must yield while waiting for the host."); + } + await disposal; + + Assert.IsTrue(process.HasExited); + Assert.IsTrue(messages.Any(x => x.Contains("Invoking kill"))); + } + finally + { + await EnsureProcessExited(process); + } + } + + private static async Task StartApp(Action? logMessage = null) + { + IApp app = await App.StartRemote(new AppOptions + { + AllowVisualStudioDebuggerAttach = false, + LogMessage = logMessage + }); + try + { + await app.InitializeWithDefaults(Assembly.GetExecutingAssembly().Location); + return app; + } + catch + { + await app.DisposeAsync(); + throw; + } + } + + private static async Task GetHostProcess(IApp app) + { + static int GetProcessId(Application _) => Environment.ProcessId; + try + { + int processId = await app.RemoteExecute(GetProcessId); + return Process.GetProcessById(processId); + } + catch + { + await app.DisposeAsync(); + throw; + } + } + + private static async Task EnsureProcessExited(Process process) + { + if (!process.HasExited) + { + process.Kill(); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + } + + private static async Task Dispose(IApp app, bool asynchronous) + { + if (asynchronous) + { + await app.DisposeAsync(); + } + else + { + app.Dispose(); + } + } +} diff --git a/XAMLTest.Tests/NativeMethods.txt b/XAMLTest.Tests/NativeMethods.txt index b28b0229..a2764d21 100644 --- a/XAMLTest.Tests/NativeMethods.txt +++ b/XAMLTest.Tests/NativeMethods.txt @@ -1 +1 @@ -GetWindowPlacement \ No newline at end of file +GetWindowPlacement diff --git a/XAMLTest/Internal/App.cs b/XAMLTest/Internal/App.cs index 647974a0..18b22db4 100644 --- a/XAMLTest/Internal/App.cs +++ b/XAMLTest/Internal/App.cs @@ -8,6 +8,9 @@ internal sealed class App( Protocol.ProtocolClient client, AppOptions appOptions) : IApp { + private static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan ProcessExitTimeout = TimeSpan.FromSeconds(10); + public Process Process { get; } = process ?? throw new ArgumentNullException(nameof(process)); private Protocol.ProtocolClient Client { get; } = client ?? throw new ArgumentNullException(nameof(client)); @@ -27,10 +30,9 @@ public void Dispose() ExitCode = 0 }; LogMessage?.Invoke($"{nameof(IApp)}.{nameof(Dispose)}()"); + using CancellationTokenSource cts = new(ShutdownTimeout); try { - using CancellationTokenSource cts = new(); - cts.CancelAfter(TimeSpan.FromSeconds(1)); if (Client.Shutdown(request, cancellationToken: cts.Token) is { } reply) { if (reply.ErrorMessages.Any()) @@ -41,14 +43,26 @@ public void Dispose() } throw new XamlTestException("Failed to get a reply"); } - catch (OperationCanceledException) - { } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + LogMessage?.Invoke("Shutdown request timed out; waiting for process exit."); + } + catch (RpcException rpcException) when (rpcException.StatusCode == StatusCode.Cancelled && cts.IsCancellationRequested) + { + LogMessage?.Invoke("Shutdown request timed out; waiting for process exit."); + } catch (RpcException rpcException) when (rpcException.StatusCode == StatusCode.Unavailable) { } finally { - KillProcess(); - CleanupLogFiles(); + try + { + KillProcess(); + } + finally + { + CleanupLogFiles(); + } } } @@ -59,10 +73,9 @@ public async ValueTask DisposeAsync() ExitCode = 0 }; LogMessage?.Invoke($"{nameof(IApp)}.{nameof(DisposeAsync)}()"); + using CancellationTokenSource cts = new(ShutdownTimeout); try { - using CancellationTokenSource cts = new(); - cts.CancelAfter(TimeSpan.FromSeconds(1)); if (await Client.ShutdownAsync(request, cancellationToken: cts.Token) is { } reply) { if (reply.ErrorMessages.Any()) @@ -73,14 +86,26 @@ public async ValueTask DisposeAsync() } throw new XamlTestException("Failed to get a reply"); } - catch (OperationCanceledException) - { } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + LogMessage?.Invoke("Shutdown request timed out; waiting for process exit."); + } + catch (RpcException rpcException) when (rpcException.StatusCode == StatusCode.Cancelled && cts.IsCancellationRequested) + { + LogMessage?.Invoke("Shutdown request timed out; waiting for process exit."); + } catch (RpcException rpcException) when (rpcException.StatusCode == StatusCode.Unavailable) { } finally { - KillProcess(); - await CleanupLogFilesAsync(); + try + { + await KillProcessAsync(); + } + finally + { + await CleanupLogFilesAsync(); + } } } @@ -133,29 +158,41 @@ private void CleanupLogFiles() private void KillProcess() { LogMessage?.Invoke("Waiting for process exit"); - using CancellationTokenSource cts = new(); - cts.CancelAfter(TimeSpan.FromSeconds(10)); - Process? process = null; - do + if (!Process.WaitForExit((int)ProcessExitTimeout.TotalMilliseconds)) { - try - { - process = Process.GetProcessById(Process.Id); - } - catch (ArgumentException) + LogMessage?.Invoke($"Invoking kill"); + Process.Kill(); + if (!Process.WaitForExit((int)ProcessExitTimeout.TotalMilliseconds)) { - //Thrown when the process specified by the processId parameter is not running. + throw new XamlTestException("Host process did not exit after being killed."); } } - while (process?.HasExited == false && !cts.IsCancellationRequested); + LogMessage?.Invoke($"Process Exited? {Process.HasExited}"); + } - LogMessage?.Invoke($"Process Exited? {process?.HasExited}"); - if (process?.HasExited == false) + private async Task KillProcessAsync() + { + LogMessage?.Invoke("Waiting for process exit"); + using CancellationTokenSource cts = new(ProcessExitTimeout); + try { - LogMessage?.Invoke($"Invoking kill"); - process.Kill(); - process.WaitForExit(1_000); + await Process.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + LogMessage?.Invoke("Invoking kill"); + Process.Kill(); + using CancellationTokenSource killCts = new(ProcessExitTimeout); + try + { + await Process.WaitForExitAsync(killCts.Token); + } + catch (OperationCanceledException e) when (killCts.IsCancellationRequested) + { + throw new XamlTestException("Host process did not exit after being killed.", e); + } } + LogMessage?.Invoke($"Process Exited? {Process.HasExited}"); } public async Task Initialize(string applicationResourceXaml, params string[] assemblies)