diff --git a/tests/dotnet/UnitTests/DotNetWatchTest.cs b/tests/dotnet/UnitTests/DotNetWatchTest.cs index 897995dfa69..f4d9eb1de2a 100644 --- a/tests/dotnet/UnitTests/DotNetWatchTest.cs +++ b/tests/dotnet/UnitTests/DotNetWatchTest.cs @@ -13,10 +13,12 @@ namespace Xamarin.Tests { [TestFixture] public class DotNetWatchTest : TestBaseClass { [Test] - [TestCase (ApplePlatform.MacOSX)] - // [TestCase (ApplePlatform.MacCatalyst)] - // [TestCase (ApplePlatform.iOS)] - public void DotNetWatch (ApplePlatform platform) + [TestCase (ApplePlatform.MacOSX, false)] + [TestCase (ApplePlatform.MacCatalyst, false)] + [TestCase (ApplePlatform.iOS, false)] + [TestCase (ApplePlatform.MacCatalyst, true)] + [TestCase (ApplePlatform.iOS, true)] + public void DotNetWatch (ApplePlatform platform, bool useMonoRuntime) { Configuration.IgnoreIfIgnoredPlatform (platform); @@ -77,8 +79,12 @@ static partial void ChangeVariable () debugLog.WriteLine ("Got 'Variable has changed'"); } if (line.Contains ("Waiting for changes")) { - waitingForChanges.TrySetResult (true); - debugLog.WriteLine ("Got 'Waiting for changes'"); + if (waitingForChanges.TrySetResult (true)) + debugLog.WriteLine ("Got 'Waiting for changes'"); + } + if (line.Contains ("Build FAILED.")) { + if (waitingForChanges.TrySetResult (false)) + debugLog.WriteLine ("Got 'Build FAILED'"); } }); @@ -117,6 +123,7 @@ static partial void ChangeVariable () var args = new List { "watch", "--non-interactive", + "--disable-build-servers", }; if (platform == ApplePlatform.iOS || platform == ApplePlatform.TVOS) { @@ -129,6 +136,8 @@ static partial void ChangeVariable () var env = new Dictionary { { "HOTRELOAD_TEST_APP_LOGFILE", logPath }, { "AdditionalFile", additionalFile }, + { "UseMonoRuntime", useMonoRuntime ? "true" : "false" }, + { "RunWithOpen", "false" }, // this makes it so that the watched process is a subprocess, which means that ctrl-c in the terminal will kill everything. It also means that it'll get killed if something times out in the test. }; var watchTask = Execution.RunWithCallbacksAsync ( @@ -140,39 +149,52 @@ static partial void ChangeVariable () workingDirectory: projectDirectory, timeout: TimeSpan.FromMinutes (10), cancellationToken: cts.Token, - log: debugLog + log: debugLog, + closeStandardInput: true ); - // Wait for the app to start and show initial output - debugLog.WriteLine ("Waiting for app start..."); - if (!appStarted.Task.Wait (TimeSpan.FromMinutes (1))) - Assert.Fail ($"Timed out waiting for the app to start. Output:\n{string.Join ("\n", output)}\nDebug output:\n{string.Join ("\n", File.ReadAllLines (debugLogPath))}"); - debugLog.WriteLine ("App started!"); - - debugLog.WriteLine ("Waiting for 'dotnet watch' to be waiting for changes..."); - if (!waitingForChanges.Task.Wait (TimeSpan.FromMinutes (1))) - Assert.Fail ($"Timed out waiting for the 'dotnet watch' to be waiting for changes. Output:\n{string.Join ("\n", output)}\nDebug output:\n{string.Join ("\n", File.ReadAllLines (debugLogPath))}"); - debugLog.WriteLine ("Waiting for changes!"); - - // Write AdditionalFile.cs to trigger a rebuild via dotnet watch - File.WriteAllText (additionalFile, secondContent); - - // Wait for dotnet watch to pick up the change and the app to show the updated output - debugLog.WriteLine ("Waiting for app restart..."); - if (!variableChanged.Task.Wait (TimeSpan.FromMinutes (1))) - Assert.Fail ($"Timed out waiting for the variable to change. Output:\n{string.Join ("\n", output)}\nDebug output:\n{string.Join ("\n", File.ReadAllLines (debugLogPath))}"); - debugLog.WriteLine ("App restarted!"); - - // Cancel the watch process - debugLog.WriteLine ("Terminating the watch process..."); - cts.Cancel (); - try { - debugLog.WriteLine ("Waiting for exit..."); - watchTask.Wait (TimeSpan.FromSeconds (30)); - debugLog.WriteLine ("Waited for exit"); - } catch { - // Expected - the process was cancelled + // Wait for the app to start and show initial output + debugLog.WriteLine ("Waiting for app start..."); + var completedTask = Task.WhenAny (appStarted.Task, waitingForChanges.Task).GetAwaiter ().GetResult (); + if (!appStarted.Task.IsCompleted) { + if (waitingForChanges.Task.IsCompleted && !waitingForChanges.Task.Result) + Assert.Fail ($"Build failed before the app could start. Output:\n{string.Join ("\n", output)}\nDebug output:\n{string.Join ("\n", File.ReadAllLines (debugLogPath))}"); + if (!appStarted.Task.Wait (TimeSpan.FromMinutes (1))) + Assert.Fail ($"Timed out waiting for the app to start. Output:\n{string.Join ("\n", output)}\nDebug output:\n{string.Join ("\n", File.ReadAllLines (debugLogPath))}"); + } + debugLog.WriteLine ("App started!"); + + debugLog.WriteLine ("Waiting for 'dotnet watch' to be waiting for changes..."); + if (!waitingForChanges.Task.Wait (TimeSpan.FromMinutes (1))) + Assert.Fail ($"Timed out waiting for the 'dotnet watch' to be waiting for changes. Output:\n{string.Join ("\n", output)}\nDebug output:\n{string.Join ("\n", File.ReadAllLines (debugLogPath))}"); + if (!waitingForChanges.Task.Result) + Assert.Fail ($"Build failed. Output:\n{string.Join ("\n", output)}\nDebug output:\n{string.Join ("\n", File.ReadAllLines (debugLogPath))}"); + debugLog.WriteLine ("Waiting for changes!"); + + // Write AdditionalFile.cs to trigger a rebuild via dotnet watch + File.WriteAllText (additionalFile, secondContent); + + // Wait for dotnet watch to pick up the change and the app to show the updated output + debugLog.WriteLine ("Waiting for app restart..."); + if (!variableChanged.Task.Wait (TimeSpan.FromMinutes (1))) + Assert.Fail ($"Timed out waiting for the variable to change. Output:\n{string.Join ("\n", output)}\nDebug output:\n{string.Join ("\n", File.ReadAllLines (debugLogPath))}"); + debugLog.WriteLine ("App restarted!"); + } finally { + // Always cancel the watch process, even if the test failed + debugLog.WriteLine ("Terminating the watch process..."); + cts.Cancel (); + + try { + debugLog.WriteLine ("Waiting for exit..."); + if (!watchTask.Wait (TimeSpan.FromSeconds (30))) + debugLog.WriteLine ("Watch process did not exit within 30 seconds."); + else + debugLog.WriteLine ("Waited for exit"); + } catch (Exception ex) { + // Expected - the process was cancelled + debugLog.WriteLine ($"Exception while waiting for exit (may be expected due to cancellation): {ex.Message}"); + } } } diff --git a/tools/common/Execution.cs b/tools/common/Execution.cs index bd136fae72f..539abfcf4c3 100644 --- a/tools/common/Execution.cs +++ b/tools/common/Execution.cs @@ -111,6 +111,7 @@ public class Execution { public string? WorkingDirectory; public TimeSpan? Timeout; public CancellationToken? CancellationToken; + public bool CloseStandardInput; public TextWriter? Log; @@ -148,6 +149,15 @@ static Thread StartOutputThread (TaskCompletionSource tcs, object loc return thread; } + static void KillProcess (Process p) + { +#if NET + p.Kill (true); +#else + p.Kill (); +#endif + } + public Task RunAsync () { var tcs = new TaskCompletionSource (); @@ -158,7 +168,7 @@ public Task RunAsync () p.StartInfo.FileName = FileName; p.StartInfo.Arguments = Arguments is not null ? StringUtils.FormatArguments (Arguments) : ""; p.StartInfo.UseShellExecute = false; - p.StartInfo.RedirectStandardInput = false; + p.StartInfo.RedirectStandardInput = CloseStandardInput; p.StartInfo.RedirectStandardOutput = true; p.StartInfo.RedirectStandardError = true; if (!string.IsNullOrEmpty (WorkingDirectory)) @@ -193,6 +203,8 @@ public Task RunAsync () var stopwatch = Stopwatch.StartNew (); p.Start (); + if (CloseStandardInput) + p.StandardInput.Close (); var pid = p.Id; var stdoutThread = StartOutputThread (tcs, lockobj, p.StandardOutput, StandardOutputLineCallback, $"StandardOutput reader for {p.StartInfo.FileName} (PID: {pid})"); @@ -201,7 +213,8 @@ public Task RunAsync () CancellationToken?.Register (() => { // Don't call tcs.TrySetCanceled, that won't return an Execution result to the caller. try { - p.Kill (); + Log?.WriteLine ($"Command '{p.StartInfo.FileName} {p.StartInfo.Arguments}' (pid: {pid}) was cancelled, and will be killed."); + KillProcess (p); } catch (Exception ex) { // The process could be disposed already. Just ignore any exceptions here. Log?.WriteLine ($"Failed to cancel and kill PID {pid}: {ex.Message}"); @@ -210,10 +223,10 @@ public Task RunAsync () if (Timeout.HasValue) { if (!p.WaitForExit ((int) Timeout.Value.TotalMilliseconds)) { - Log?.WriteLine ($"Command '{p.StartInfo.FileName} {p.StartInfo.Arguments}' didn't finish in {Timeout.Value.TotalMilliseconds} ms, and will be killed."); + Log?.WriteLine ($"Command '{p.StartInfo.FileName} {p.StartInfo.Arguments}' (pid: {pid}) didn't finish in {Timeout.Value.TotalMilliseconds} ms, and will be killed."); TimedOut = true; try { - p.Kill (); + KillProcess (p); } catch (Exception ex) { // According to the documentation, there can be exceptions here we can't prepare for, so just ignore them. Log?.WriteLine ($"Failed to kill PID {pid}: {ex.Message}"); @@ -247,7 +260,7 @@ public Task RunAsync () return tcs.Task; } - public static Task RunWithCallbacksAsync (string filename, IList arguments, Dictionary? environment = null, Action? standardOutput = null, Action? standardError = null, TextWriter? log = null, string? workingDirectory = null, TimeSpan? timeout = null, CancellationToken? cancellationToken = null) + public static Task RunWithCallbacksAsync (string filename, IList arguments, Dictionary? environment = null, Action? standardOutput = null, Action? standardError = null, TextWriter? log = null, string? workingDirectory = null, TimeSpan? timeout = null, CancellationToken? cancellationToken = null, bool closeStandardInput = false) { return new Execution { FileName = filename, @@ -259,6 +272,7 @@ public static Task RunWithCallbacksAsync (string filename, IList