From 02c79ae06276cb82880116e2066f8ad081f1ec20 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Tue, 5 May 2026 17:47:11 +0200 Subject: [PATCH 1/2] [net11.0] 'dotnet watch' works again now, so enable the corresponding tests. Also a few improvements, because this test turned out rather unpredictable locally: * Detect if the build failed, and terminate the test early. The build shouldn't fail, but yet it randomly does, and this makes the test fail immediately instead of having the test time out (after a 2 min wait). * Don't give stdin to 'dotnet watch', because 'dotnet test' goes bananas. Deadlocks everywhere (dozens of threads stuck while trying to determine the cursor position in the terminal, etc). * Disable build servers. Not sure if it helps, but when random things fail, random stuff gets tried, and this won't hurt. * Don't run desktop test apps with 'open', because then the test app's process is not a descendent of the 'dotnet watch' process, and if something goes wrong and the test needs to kill 'dotnet watch', the test app won't be killed. * Always cancel the 'dotnet watch' process at the end. Copilot suggested doing this, and it sounded good so here it is. * Augment the 'Execution' class to kill the entire process tree. Once again, Copilot suggested doing this, and it sounds like a good thing to do. * Add CoreCLR mobile variations. --- tests/dotnet/UnitTests/DotNetWatchTest.cs | 85 +++++++++++++---------- tools/common/Execution.cs | 24 +++++-- 2 files changed, 68 insertions(+), 41 deletions(-) diff --git a/tests/dotnet/UnitTests/DotNetWatchTest.cs b/tests/dotnet/UnitTests/DotNetWatchTest.cs index 897995dfa691..e9f6cd868721 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,43 @@ 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..."); + 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!"); + Assert.That (waitingForChanges.Task.Result, Is.True, "Build failed"); + + // 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..."); + watchTask.Wait (TimeSpan.FromSeconds (30)); + debugLog.WriteLine ("Waited for exit"); + } catch { + // Expected - the process was cancelled + } } } diff --git a/tools/common/Execution.cs b/tools/common/Execution.cs index bd136fae72f7..b6b8e19b3bc7 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, int pid, TextWriter? log) + { +#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, pid, Log); } 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, pid, Log); } 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 Date: Thu, 18 Jun 2026 16:35:16 +0200 Subject: [PATCH 2/2] Address PR review comments - Remove unused pid/log parameters from KillProcess. - Detect build failures early while waiting for app start, instead of timing out after 1 minute. - Move 'Waiting for changes!' log after confirming the build succeeded. - Log exceptions and timeout in cleanup path instead of swallowing them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/dotnet/UnitTests/DotNetWatchTest.cs | 21 +++++++++++++++------ tools/common/Execution.cs | 6 +++--- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/tests/dotnet/UnitTests/DotNetWatchTest.cs b/tests/dotnet/UnitTests/DotNetWatchTest.cs index e9f6cd868721..f4d9eb1de2a2 100644 --- a/tests/dotnet/UnitTests/DotNetWatchTest.cs +++ b/tests/dotnet/UnitTests/DotNetWatchTest.cs @@ -156,15 +156,21 @@ static partial void ChangeVariable () try { // 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))}"); + 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!"); - Assert.That (waitingForChanges.Task.Result, Is.True, "Build failed"); // Write AdditionalFile.cs to trigger a rebuild via dotnet watch File.WriteAllText (additionalFile, secondContent); @@ -181,10 +187,13 @@ static partial void ChangeVariable () try { debugLog.WriteLine ("Waiting for exit..."); - watchTask.Wait (TimeSpan.FromSeconds (30)); - debugLog.WriteLine ("Waited for exit"); - } catch { + 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 b6b8e19b3bc7..539abfcf4c37 100644 --- a/tools/common/Execution.cs +++ b/tools/common/Execution.cs @@ -149,7 +149,7 @@ static Thread StartOutputThread (TaskCompletionSource tcs, object loc return thread; } - static void KillProcess (Process p, int pid, TextWriter? log) + static void KillProcess (Process p) { #if NET p.Kill (true); @@ -214,7 +214,7 @@ public Task RunAsync () // Don't call tcs.TrySetCanceled, that won't return an Execution result to the caller. try { Log?.WriteLine ($"Command '{p.StartInfo.FileName} {p.StartInfo.Arguments}' (pid: {pid}) was cancelled, and will be killed."); - KillProcess (p, pid, Log); + 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}"); @@ -226,7 +226,7 @@ public Task RunAsync () 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 { - KillProcess (p, pid, Log); + 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}");