From 5ca5b77de9b7f5f617ab30e532dcead3a9bad4e7 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 5 Feb 2026 16:59:20 -0600 Subject: [PATCH 1/9] [xabt] `dotnet watch` support, based on env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: https://github.com/dotnet/sdk/issues/52492 Context: https://github.com/dotnet/sdk/pull/52581 `dotnet-watch` now runs Android applications via: dotnet watch 🚀 [helloandroid (net10.0-android)] Launched 'D:\src\xamarin-android\bin\Debug\dotnet\dotnet.exe' with arguments 'run --no-build -e DOTNET_WATCH=1 -e DOTNET_WATCH_ITERATION=1 -e DOTNET_MODIFIABLE_ASSEMBLIES=debug -e DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT=ws://localhost:9000 -e DOTNET_STARTUP_HOOKS=D:\src\xamarin-android\bin\Debug\dotnet\sdk\10.0.300-dev\DotnetTools\dotnet-watch\10.0.300-dev\tools\net10.0\any\hotreload\net10.0\Microsoft.Extensions.DotNetDeltaApplier.dll -bl': process id 3356 And so the pieces on Android for this to work are: ~~ Startup Hook Assembly ~~ Parse out the value: <_AndroidHotReloadAgentAssemblyPath>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_STARTUP_HOOKS')->'%(Value)'->Exists()) And verify this assembly is included in the app: Then, for Android, we need to patch up `$DOTNET_STARTUP_HOOKS` to be just the assembly name, not the full path: <_AndroidHotReloadAgentAssemblyName>$([System.IO.Path]::GetFileNameWithoutExtension('$(_AndroidHotReloadAgentAssemblyPath)')) ... ~~ Port Forwarding ~~ A new `_AndroidConfigureAdbReverse` target runs after deploying apps, that does: adb reverse tcp:9000 tcp:9000 I parsed the value out of: <_AndroidWebSocketEndpoint>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT')->'%(Value)') <_AndroidWebSocketPort>$([System.Text.RegularExpressions.Regex]::Match('$(_AndroidWebSocketEndpoint)', ':(\d+)').Groups[1].Value) ~~ Prevent Startup Hooks in Microsoft.Android.Run ~~ When I was implementing this, I keep seeing *two* clients connect to `dotnet-watch` and I was pulling my hair to figure out why! Then I realized that `Microsoft.Android.Run` was also getting `$DOTNET_STARTUP_HOOKS`, and so we had a desktop process + mobile process both trying to connect! Easiest fix, is to disable startup hook support in `Microsoft.Android.Run`. I reviewed the code in `dotnet run`, and it doesn't seem correct to try to clear the env vars. ~~ Conclusion ~~ With these changes, everything is working! dotnet watch 🔥 C# and Razor changes applied in 23ms. This will depend on getting changes in dotnet/sdk before we merge. --- .../Microsoft.Android.Run.csproj | 1 + .../Microsoft.Android.Sdk.After.targets | 1 + .../Microsoft.Android.Sdk.Application.targets | 14 +++- ...oft.Android.Sdk.AssemblyResolution.targets | 3 +- .../Microsoft.Android.Sdk.BuildOrder.targets | 5 ++ .../Microsoft.Android.Sdk.HotReload.targets | 84 +++++++++++++++++++ ...ft.Android.Sdk.ProjectCapabilities.targets | 1 + .../Xamarin.Android.Common.targets | 2 +- 8 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.HotReload.targets diff --git a/src/Microsoft.Android.Run/Microsoft.Android.Run.csproj b/src/Microsoft.Android.Run/Microsoft.Android.Run.csproj index 364ff7732a4..df2d125fd43 100644 --- a/src/Microsoft.Android.Run/Microsoft.Android.Run.csproj +++ b/src/Microsoft.Android.Run/Microsoft.Android.Run.csproj @@ -11,6 +11,7 @@ enable portable Major + false diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.After.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.After.targets index 9cb93fb2e06..01a64d12687 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.After.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.After.targets @@ -33,4 +33,5 @@ This file is imported *after* the Microsoft.NET.Sdk/Sdk.targets. + diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets index 14edfd0270a..8d266e6b253 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets @@ -25,6 +25,7 @@ This file contains targets specific for Android application projects. <_AndroidComputeRunArgumentsDependsOn Condition=" '$(_AndroidComputeRunArgumentsDependsOn)' == '' and $([MSBuild]::VersionGreaterThanOrEquals($(NetCoreSdkVersion), 10.0.300)) "> _ResolveMonoAndroidSdks; _GetAndroidPackageName; + _AndroidAdbToolPath; <_AndroidComputeRunArgumentsDependsOn Condition=" '$(_AndroidComputeRunArgumentsDependsOn)' == '' "> Install; @@ -52,6 +53,15 @@ This file contains targets specific for Android application projects. + + + <_AdbToolPath>$(AdbToolExe) + <_AdbToolPath Condition=" '$(_AdbToolPath)' == '' and $([MSBuild]::IsOSPlatform('windows')) ">adb.exe + <_AdbToolPath Condition=" '$(_AdbToolPath)' == '' and !$([MSBuild]::IsOSPlatform('windows')) ">adb + <_AdbToolPath>$([System.IO.Path]::Combine ('$(AdbToolPath)', '$(_AdbToolPath)')) + + + @@ -61,10 +71,6 @@ This file contains targets specific for Android application projects. - <_AdbToolPath>$(AdbToolExe) - <_AdbToolPath Condition=" '$(_AdbToolPath)' == '' and $([MSBuild]::IsOSPlatform('windows')) ">adb.exe - <_AdbToolPath Condition=" '$(_AdbToolPath)' == '' and !$([MSBuild]::IsOSPlatform('windows')) ">adb - <_AdbToolPath>$([System.IO.Path]::Combine ('$(AdbToolPath)', '$(_AdbToolPath)')) $(MSBuildProjectDirectory) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets index 9cc9b0e77b7..4c816f08a44 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets @@ -73,7 +73,7 @@ _ResolveAssemblies MSBuild target. - + <_RIDs Include="$(RuntimeIdentifier)" Condition=" '$(RuntimeIdentifiers)' == '' " /> <_RIDs Include="$(RuntimeIdentifiers)" Condition=" '$(RuntimeIdentifiers)' != '' " /> @@ -119,6 +119,7 @@ _ResolveAssemblies MSBuild target. + diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.BuildOrder.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.BuildOrder.targets index 039bc54f78a..9bb66ab4073 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.BuildOrder.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.BuildOrder.targets @@ -99,6 +99,7 @@ properties that determine build ordering. SignAndroidPackage; _DeployApk; _DeployAppBundle; + _AndroidConfigureAdbReverse; AndroidPrepareForBuild; @@ -127,12 +128,16 @@ properties that determine build ordering. $(_MinimalSignAndroidPackageDependsOn); + _GenerateEnvironmentFiles; _Upload; + _AndroidConfigureAdbReverse; $(_MinimalSignAndroidPackageDependsOn); + _GenerateEnvironmentFiles; _DeployApk; _DeployAppBundle; + _AndroidConfigureAdbReverse; diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.HotReload.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.HotReload.targets new file mode 100644 index 00000000000..0e887dcf6c6 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.HotReload.targets @@ -0,0 +1,84 @@ + + + + + + + + + + <_AndroidHotReloadAgentAssemblyPath>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_STARTUP_HOOKS')->'%(Value)'->Exists()) + + + + + <_AndroidHotReloadAgentAssemblyName>$([System.IO.Path]::GetFileNameWithoutExtension('$(_AndroidHotReloadAgentAssemblyPath)')) + + + + + + + + + + + + + + + + + + <_AndroidWebSocketEndpoint>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT')->'%(Value)') + + + + + <_AndroidWebSocketPort>$([System.UriBuilder]::new('$(_AndroidWebSocketEndpoint)').Port) + + <_AndroidWebSocketPort Condition=" '$(_AndroidWebSocketPort)' == '-1' "> + + + + + + + + diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.ProjectCapabilities.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.ProjectCapabilities.targets index fef1c290dfb..502c2460fe7 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.ProjectCapabilities.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.ProjectCapabilities.targets @@ -17,6 +17,7 @@ Docs about @(ProjectCapability): + diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index f01393f6565..7a55d177bed 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -1773,7 +1773,7 @@ because xbuild doesn't support framework reference assemblies. - + <_GeneratedAndroidEnvironment Include="mono.enable_assembly_preload=0" Condition=" '$(AndroidEnablePreloadAssemblies)' != 'True' " /> <_GeneratedAndroidEnvironment Include="DOTNET_MODIFIABLE_ASSEMBLIES=Debug" Condition=" '$(AndroidIncludeDebugSymbols)' == 'true' and '$(AndroidUseInterpreter)' == 'true' " /> From 2a9bad96de649e2b2b9b0e42a0300dc0c384e63a Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Mon, 2 Mar 2026 15:59:01 -0600 Subject: [PATCH 2/9] Add test --- .../Xamarin.ProjectTools/Common/DotNetCLI.cs | 21 ++++ .../Tests/InstallAndRunTests.cs | 112 ++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs index 46fbc3ecae4..b30efc85c1f 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs @@ -172,6 +172,27 @@ public Process StartRun (bool waitForExit = true, string [] parameters = null) return ExecuteProcess (arguments.ToArray ()); } + /// + /// Starts `dotnet watch` and returns a running Process that can be monitored and killed. + /// This is used for hot reload testing where dotnet-watch builds, deploys, and watches for file changes. + /// + /// Additional arguments to pass to `dotnet watch`. + /// A running Process instance. Caller is responsible for disposing. + public Process StartWatch (string [] parameters = null) + { + var arguments = new List { + "watch", + "--project", $"\"{projectOrSolution}\"", + "--non-interactive", + "--verbose", + }; + if (parameters != null) { + arguments.AddRange (parameters); + } + + return ExecuteProcess (arguments.ToArray ()); + } + public IEnumerable LastBuildOutput { get { if (!string.IsNullOrEmpty (BuildLogFile) && File.Exists (BuildLogFile)) { diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index 11be3075d1c..69ce0951353 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -198,6 +198,118 @@ public void DotNetRunWithDeviceParameter () Assert.IsTrue (foundMessage, $"Expected message '{logcatMessage}' was not found in output. See {logPath} for details."); } + [Test] + public void DotNetWatchHotReload () + { + const string initialMessage = "DOTNET_WATCH_INITIAL_12345"; + const string hotReloadMessage = "DOTNET_WATCH_HOT_RELOAD_APPLIED"; + + var proj = new XamarinAndroidApplicationProject (); + proj.SetProperty ("AndroidUseInterpreter", "true"); + + // Add a Console.WriteLine that will appear in logcat + proj.MainActivity = proj.DefaultMainActivity.Replace ( + "//${AFTER_ONCREATE}", + $"Console.WriteLine (\"{initialMessage}\");"); + + // Add a MetadataUpdateHandler that logs when hot reload is applied + proj.Sources.Add (new BuildItem.Source ("HotReloadService.cs") { + TextContent = () => +@"using System; + +[assembly: System.Reflection.Metadata.MetadataUpdateHandlerAttribute (typeof (UnderTest.HotReloadService))] + +namespace UnderTest +{ + public static class HotReloadService + { + internal static void ClearCache (Type[]? types) { } + internal static void UpdateApplication (Type[]? types) + { + Console.WriteLine (""" + hotReloadMessage + @"""); + } + } +} +" + }); + + using var builder = CreateApkBuilder (); + builder.Save (proj); + + var dotnet = new DotNetCLI (Path.Combine (Root, builder.ProjectDirectory, proj.ProjectFilePath)); + + // Start dotnet watch which will build, deploy, and watch for changes + using var process = dotnet.StartWatch (); + + var locker = new Lock (); + var output = new StringBuilder (); + var initialMessageEvent = new ManualResetEventSlim (); + var hotReloadAppliedEvent = new ManualResetEventSlim (); + bool foundInitialMessage = false; + bool foundHotReloadMessage = false; + + process.OutputDataReceived += (sender, e) => { + if (e.Data != null) { + lock (locker) { + output.AppendLine (e.Data); + if (e.Data.Contains (initialMessage)) { + foundInitialMessage = true; + initialMessageEvent.Set (); + } + if (e.Data.Contains (hotReloadMessage)) { + foundHotReloadMessage = true; + hotReloadAppliedEvent.Set (); + } + } + } + }; + process.ErrorDataReceived += (sender, e) => { + if (e.Data != null) { + lock (locker) { + output.AppendLine ($"STDERR: {e.Data}"); + // dotnet watch status messages (e.g., "changes applied") go to stderr + if (e.Data.Contains (hotReloadMessage)) { + foundHotReloadMessage = true; + hotReloadAppliedEvent.Set (); + } + } + } + }; + + process.BeginOutputReadLine (); + process.BeginErrorReadLine (); + + string logPath = Path.Combine (Root, builder.ProjectDirectory, "dotnet-watch-output.log"); + + try { + // Wait for the initial message to appear (app launched and running) + Assert.IsTrue (initialMessageEvent.Wait (TimeSpan.FromMinutes (5)), + $"Initial message '{initialMessage}' was not found in output. See {logPath} for details."); + + // Modify the source file to trigger hot reload + string mainActivityPath = Path.Combine (Root, builder.ProjectDirectory, "MainActivity.cs"); + string content = File.ReadAllText (mainActivityPath); + content = content.Replace ( + $"Console.WriteLine (\"{initialMessage}\");", + $"Console.WriteLine (\"{initialMessage}\");\n\t\t\tConsole.WriteLine (\"MODIFIED_LINE\");"); + File.WriteAllText (mainActivityPath, content); + + // Wait for hot reload to apply (MetadataUpdateHandler fires Console.WriteLine) + Assert.IsTrue (hotReloadAppliedEvent.Wait (TimeSpan.FromMinutes (2)), + $"Hot reload message '{hotReloadMessage}' was not found in output. See {logPath} for details."); + } finally { + // Kill the process + if (!process.HasExited) { + process.Kill (entireProcessTree: true); + process.WaitForExit (); + } + + // Write the output to a log file for debugging + File.WriteAllText (logPath, output.ToString ()); + TestContext.AddTestAttachment (logPath); + } + } + [Test] [TestCase (true)] [TestCase (false)] From 1fe3692680d1f30dc1df767bf0d969393e98babe Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 4 Mar 2026 08:17:38 -0600 Subject: [PATCH 3/9] More `dotnet-watch` logging --- .../Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs | 2 +- .../Tests/InstallAndRunTests.cs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs index b30efc85c1f..414e2150bf2 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs @@ -184,7 +184,7 @@ public Process StartWatch (string [] parameters = null) "watch", "--project", $"\"{projectOrSolution}\"", "--non-interactive", - "--verbose", + "--verbosity", "diag", }; if (parameters != null) { arguments.AddRange (parameters); diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index 69ce0951353..afcff02c5e8 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -207,6 +207,11 @@ public void DotNetWatchHotReload () var proj = new XamarinAndroidApplicationProject (); proj.SetProperty ("AndroidUseInterpreter", "true"); + // Enable hot reload log messages from the delta client + proj.OtherBuildItems.Add (new BuildItem ("AndroidEnvironment", "env.txt") { + TextContent = () => "HOTRELOAD_DELTA_CLIENT_LOG_MESSAGES=[HotReload]", + }); + // Add a Console.WriteLine that will appear in logcat proj.MainActivity = proj.DefaultMainActivity.Replace ( "//${AFTER_ONCREATE}", @@ -245,19 +250,15 @@ internal static void UpdateApplication (Type[]? types) var output = new StringBuilder (); var initialMessageEvent = new ManualResetEventSlim (); var hotReloadAppliedEvent = new ManualResetEventSlim (); - bool foundInitialMessage = false; - bool foundHotReloadMessage = false; process.OutputDataReceived += (sender, e) => { if (e.Data != null) { lock (locker) { output.AppendLine (e.Data); if (e.Data.Contains (initialMessage)) { - foundInitialMessage = true; initialMessageEvent.Set (); } if (e.Data.Contains (hotReloadMessage)) { - foundHotReloadMessage = true; hotReloadAppliedEvent.Set (); } } @@ -269,7 +270,6 @@ internal static void UpdateApplication (Type[]? types) output.AppendLine ($"STDERR: {e.Data}"); // dotnet watch status messages (e.g., "changes applied") go to stderr if (e.Data.Contains (hotReloadMessage)) { - foundHotReloadMessage = true; hotReloadAppliedEvent.Set (); } } From 02188e06585759963c7b7d96ff364da4bdfa6898 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 4 Mar 2026 08:21:44 -0600 Subject: [PATCH 4/9] Use proper APIs to edit files in MSBuild tests --- .github/copilot-instructions.md | 15 +++++++++++++++ .../Tests/InstallAndRunTests.cs | 7 +++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 32b29dd094f..b472edfe8ea 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -150,6 +150,21 @@ try { } ``` +## Testing + +**Modifying project files in tests:** Never use `File.WriteAllText()` directly to update project source files. Instead, use the `Xamarin.ProjectTools` infrastructure: + +```csharp +// 1. Update the in-memory content +proj.MainActivity = proj.MainActivity.Replace ("old text", "new text"); +// 2. Bump the timestamp so UpdateProjectFiles knows it changed +proj.Touch ("MainActivity.cs"); +// 3. Write to disk (doNotCleanupOnUpdate preserves other files, saveProject: false skips .csproj regeneration) +builder.Save (proj, doNotCleanupOnUpdate: true, saveProject: false); +``` + +This pattern ensures proper encoding, timestamps, and file attributes are handled correctly. The `Touch` + `Save` pattern is used throughout the test suite for incremental builds and file modifications. + ## Error Patterns - **MSBuild Errors:** `XA####` (errors), `XA####` (warnings), `APT####` (Android tools) - **Logging:** Use `Log.LogError`, `Log.LogWarning` with error codes and context diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index afcff02c5e8..df75e156c7a 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -287,12 +287,11 @@ internal static void UpdateApplication (Type[]? types) $"Initial message '{initialMessage}' was not found in output. See {logPath} for details."); // Modify the source file to trigger hot reload - string mainActivityPath = Path.Combine (Root, builder.ProjectDirectory, "MainActivity.cs"); - string content = File.ReadAllText (mainActivityPath); - content = content.Replace ( + proj.MainActivity = proj.MainActivity.Replace ( $"Console.WriteLine (\"{initialMessage}\");", $"Console.WriteLine (\"{initialMessage}\");\n\t\t\tConsole.WriteLine (\"MODIFIED_LINE\");"); - File.WriteAllText (mainActivityPath, content); + proj.Touch ("MainActivity.cs"); + builder.Save (proj, doNotCleanupOnUpdate: true, saveProject: false); // Wait for hot reload to apply (MetadataUpdateHandler fires Console.WriteLine) Assert.IsTrue (hotReloadAppliedEvent.Wait (TimeSpan.FromMinutes (2)), From 17da989165b0e50cbe26a9603d022176af22f393 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 4 Mar 2026 09:51:56 -0600 Subject: [PATCH 5/9] Moar logs, add sleep --- .../Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs | 1 + tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs index 414e2150bf2..ffc8184e248 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs @@ -184,6 +184,7 @@ public Process StartWatch (string [] parameters = null) "watch", "--project", $"\"{projectOrSolution}\"", "--non-interactive", + "--verbose", "--verbosity", "diag", }; if (parameters != null) { diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index df75e156c7a..f8d90c51851 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -268,7 +268,6 @@ internal static void UpdateApplication (Type[]? types) if (e.Data != null) { lock (locker) { output.AppendLine ($"STDERR: {e.Data}"); - // dotnet watch status messages (e.g., "changes applied") go to stderr if (e.Data.Contains (hotReloadMessage)) { hotReloadAppliedEvent.Set (); } @@ -286,6 +285,10 @@ internal static void UpdateApplication (Type[]? types) Assert.IsTrue (initialMessageEvent.Wait (TimeSpan.FromMinutes (5)), $"Initial message '{initialMessage}' was not found in output. See {logPath} for details."); + // Give dotnet watch time to finish post-deploy setup and start its file watcher. + // There is no explicit "ready" signal from dotnet watch after deploy completes. + Thread.Sleep (5000); + // Modify the source file to trigger hot reload proj.MainActivity = proj.MainActivity.Replace ( $"Console.WriteLine (\"{initialMessage}\");", From 6832058c160c300987c9ac1517cd8c1507f3d36c Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 4 Mar 2026 13:23:41 -0600 Subject: [PATCH 6/9] Fix `dotnet-watch` working directory --- .../Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs index ffc8184e248..c5067832963 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs @@ -31,7 +31,7 @@ public DotNetCLI (string projectOrSolution) /// /// command arguments /// A started Process instance. Caller is responsible for disposing. - protected Process ExecuteProcess (params string [] args) + protected Process ExecuteProcess (string [] args, string workingDirectory = null) { var p = new Process (); p.StartInfo.FileName = Path.Combine (TestEnvironment.DotNetPreviewDirectory, "dotnet"); @@ -40,6 +40,9 @@ protected Process ExecuteProcess (params string [] args) p.StartInfo.UseShellExecute = false; p.StartInfo.RedirectStandardOutput = true; p.StartInfo.RedirectStandardError = true; + if (!string.IsNullOrEmpty (workingDirectory)) { + p.StartInfo.WorkingDirectory = workingDirectory; + } p.StartInfo.SetEnvironmentVariable ("DOTNET_MULTILEVEL_LOOKUP", "0"); p.StartInfo.SetEnvironmentVariable ("PATH", TestEnvironment.DotNetPreviewDirectory + Path.PathSeparator + Environment.GetEnvironmentVariable ("PATH")); if (TestEnvironment.UseLocalBuildOutput) { @@ -191,7 +194,7 @@ public Process StartWatch (string [] parameters = null) arguments.AddRange (parameters); } - return ExecuteProcess (arguments.ToArray ()); + return ExecuteProcess (arguments.ToArray (), workingDirectory: ProjectDirectory); } public IEnumerable LastBuildOutput { From 9c1d916dc20c3f25b300dfa3b09806eb105810f7 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 4 Mar 2026 16:18:45 -0600 Subject: [PATCH 7/9] Missing `-bl` --- .../Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs index c5067832963..a3f0ae63fe8 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs @@ -189,6 +189,7 @@ public Process StartWatch (string [] parameters = null) "--non-interactive", "--verbose", "--verbosity", "diag", + "-bl", }; if (parameters != null) { arguments.AddRange (parameters); From 1ed1875bbb928147f3141cef2973589900c2f85c Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 5 Mar 2026 11:55:05 -0600 Subject: [PATCH 8/9] Test on CoreCLR --- tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index f8d90c51851..ab4a0ccf4e0 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -205,7 +205,7 @@ public void DotNetWatchHotReload () const string hotReloadMessage = "DOTNET_WATCH_HOT_RELOAD_APPLIED"; var proj = new XamarinAndroidApplicationProject (); - proj.SetProperty ("AndroidUseInterpreter", "true"); + proj.SetRuntime (AndroidRuntime.CoreCLR); // CoreCLR only for now, as MonoVM requires: https://github.com/dotnet/runtime/commit/c8e2a6110c69601540c25f2099053505fa088b9e // Enable hot reload log messages from the delta client proj.OtherBuildItems.Add (new BuildItem ("AndroidEnvironment", "env.txt") { From 80f17a8b3ec967b94483691a88d9b2c8510dcf06 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 5 Mar 2026 16:02:53 -0600 Subject: [PATCH 9/9] [tests] Make ProjectBuilder.BuiltBefore public for external build tools DotNetCLI.StartWatch() and StartRun() build outside of ProjectBuilder.Build(), leaving BuiltBefore false. This caused Save() to delete and recreate the project directory on subsequent calls, destroying the .csproj while dotnet watch was running. --- .../Xamarin.ProjectTools/Common/ProjectBuilder.cs | 13 +++++++++---- .../Tests/InstallAndRunTests.cs | 1 + 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/ProjectBuilder.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/ProjectBuilder.cs index f0345a776e2..db607aa6475 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/ProjectBuilder.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/ProjectBuilder.cs @@ -74,9 +74,14 @@ protected override void Dispose (bool disposing) Cleanup (); } - bool built_before; bool last_build_result; + /// + /// Indicates whether the project has been built at least once. + /// Set to true after Build(), or manually when using external build tools (e.g. DotNetCLI.StartWatch). + /// + public bool BuiltBefore { get; set; } + /// /// Gets the build output from the last build operation. /// @@ -97,7 +102,7 @@ public void Save (XamarinProject project, bool doNotCleanupOnUpdate = false, boo { var files = project.Save (saveProject); - if (!built_before) { + if (!BuiltBefore) { if (project.ShouldPopulate) { if (Directory.Exists (ProjectDirectory)) { FileSystemUtils.SetDirectoryWriteable (ProjectDirectory); @@ -130,7 +135,7 @@ public bool Build (XamarinProject project, bool doNotCleanupOnUpdate = false, st Output = project.CreateBuildOutput (this); bool result = BuildInternal (Path.Combine (ProjectDirectory, project.ProjectFilePath), Target, parameters, environmentVariables, restore: project.ShouldRestorePackageReferences, binlogName: Path.GetFileNameWithoutExtension (BuildLogFile)); - built_before = true; + BuiltBefore = true; if (CleanupAfterSuccessfulBuild) Cleanup (); @@ -194,7 +199,7 @@ public void Cleanup () //logs if (!last_build_result) return; - built_before = false; + BuiltBefore = false; var projectDirectory = Path.Combine (XABuildPaths.TestOutputDirectory, ProjectDirectory); if (Directory.Exists (projectDirectory)) { diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index ab4a0ccf4e0..eeaa4f94702 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -294,6 +294,7 @@ internal static void UpdateApplication (Type[]? types) $"Console.WriteLine (\"{initialMessage}\");", $"Console.WriteLine (\"{initialMessage}\");\n\t\t\tConsole.WriteLine (\"MODIFIED_LINE\");"); proj.Touch ("MainActivity.cs"); + builder.BuiltBefore = true; // dotnet watch will build, not builder.Build() builder.Save (proj, doNotCleanupOnUpdate: true, saveProject: false); // Wait for hot reload to apply (MetadataUpdateHandler fires Console.WriteLine)