From 1d61a70bed94aeecbc236467be81c8b2c2072a81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 21:06:54 +0000 Subject: [PATCH 1/3] Initial plan From d7074f5d720471514580750a0414b3e0a862e7f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 21:15:14 +0000 Subject: [PATCH 2/3] Launch emulator in signal-isolated process group on Unix to survive Ctrl+C On Unix, wrap the emulator launch through /bin/sh with 'trap "" INT; exec ...' which sets SIGINT to SIG_IGN before exec'ing the emulator. POSIX guarantees SIG_IGN is preserved across exec, so the emulator process ignores SIGINT (Ctrl+C) from the parent terminal. Also fix pre-existing bug where emulatorProcess.ExitCode was accessed after Dispose() in BootEmulatorAsync. Agent-Logs-Url: https://github.com/dotnet/android-tools/sessions/9eb96c77-ebf5-45a1-8e00-d54a177d6ef3 Co-authored-by: jonathanpeppers <840039+jonathanpeppers@users.noreply.github.com> --- .../Runners/EmulatorRunner.cs | 25 +++++++- .../EmulatorRunnerTests.cs | 64 ++++++++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index 4a4bd6e8..0bb59bd3 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; @@ -57,7 +58,22 @@ public Process LaunchEmulator (string avdName, bool coldBoot = false, List BootEmulatorAsync ( // Detect early process exit for fast failure if (emulatorProcess.HasExited && !processExitedWithZero) { if (emulatorProcess.ExitCode != 0) { + int exitCode = emulatorProcess.ExitCode; emulatorProcess.Dispose (); return new EmulatorBootResult { Success = false, ErrorKind = EmulatorBootErrorKind.LaunchFailed, - ErrorMessage = $"Emulator process for '{deviceOrAvdName}' exited with code {emulatorProcess.ExitCode} before becoming available.", + ErrorMessage = $"Emulator process for '{deviceOrAvdName}' exited with code {exitCode} before becoming available.", }; } // Exit code 0: emulator likely forked (common on macOS). @@ -308,5 +325,9 @@ async Task WaitForFullBootAsync ( cancellationToken.ThrowIfCancellationRequested (); return new EmulatorBootResult { Success = false, ErrorKind = EmulatorBootErrorKind.Cancelled, ErrorMessage = "Boot cancelled." }; } + + /// Quotes a string for safe use in a POSIX shell command. + /// Wraps in single quotes and escapes embedded single quotes. + static string ShellQuote (string arg) => "'" + arg.Replace ("'", "'\\''") + "'"; } diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs index 39fdca5b..80005c83 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -205,7 +205,7 @@ public async Task LaunchFailure_ReturnsError () var result = await runner.BootEmulatorAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsFalse (result.Success); - Assert.That (result.ErrorMessage, Does.Contain ("Failed to launch")); + Assert.AreEqual (EmulatorBootErrorKind.LaunchFailed, result.ErrorKind); } [Test] @@ -482,6 +482,68 @@ public void BootEmulatorAsync_EmptyDeviceName_Throws () // --- Helpers --- + [Test] + [Platform ("Linux,MacOsX")] + public void LaunchEmulator_SurvivesSigint () + { + // Verify that the emulator process launched by LaunchEmulator ignores + // SIGINT (Ctrl+C) so it is not killed when the parent receives the signal. + var (tempDir, emuPath) = CreateFakeEmulatorSdk (); + try { + var runner = new EmulatorRunner (emuPath); + using var process = runner.LaunchEmulator ("TestAVD"); + + Assert.IsFalse (process.HasExited, "Process should be running after launch"); + + // Send SIGINT to the emulator process + var killPsi = ProcessUtils.CreateProcessStartInfo ("kill", "-INT", process.Id.ToString ()); + using var kill = new Process { StartInfo = killPsi }; + kill.Start (); + kill.WaitForExit (5000); + + // Give the signal a moment to be delivered + Thread.Sleep (500); + + Assert.IsFalse (process.HasExited, "Emulator process should survive SIGINT"); + + process.Kill (); + process.WaitForExit (5000); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + [Platform ("Linux,MacOsX")] + public void ShellQuote_EscapesSingleQuotes () + { + // Verify that paths with special characters are handled correctly + // by launching a fake emulator with a path containing a single quote. + var tempDir = Path.Combine (Path.GetTempPath (), $"emu-quote-test-{Path.GetRandomFileName ()}"); + var emulatorDir = Path.Combine (tempDir, "emu'dir"); + Directory.CreateDirectory (emulatorDir); + + var emuPath = Path.Combine (emulatorDir, "emulator"); + File.WriteAllText (emuPath, "#!/bin/sh\nsleep 60\n"); + var psi = ProcessUtils.CreateProcessStartInfo ("chmod", "+x", emuPath); + using (var chmod = new Process { StartInfo = psi }) { + chmod.Start (); + chmod.WaitForExit (); + } + + try { + var runner = new EmulatorRunner (emuPath); + using var process = runner.LaunchEmulator ("TestAVD"); + + Assert.IsFalse (process.HasExited, "Process should start even with single-quote in path"); + + process.Kill (); + process.WaitForExit (5000); + } finally { + Directory.Delete (tempDir, true); + } + } + static (string tempDir, string emulatorPath) CreateFakeEmulatorSdk () { var tempDir = Path.Combine (Path.GetTempPath (), $"emu-boot-test-{Path.GetRandomFileName ()}"); From a7a35a90542aa7f86fa5248eb2c288e1a6033d1c Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 7 May 2026 16:47:39 -0500 Subject: [PATCH 3/3] Address PR review feedback - Add POSIX spec link to the trap/exec code comment - Remove section separator comments (// --- Helpers --- etc.) - Assert kill exit code in SurvivesSigint test to prevent false positives - Add timeout to chmod.WaitForExit() to prevent CI hangs - Move process cleanup to finally blocks to avoid leaking processes in CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runners/EmulatorRunner.cs | 3 +- .../EmulatorRunnerTests.cs | 31 +++++++------------ 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index 0bb59bd3..76152fe7 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -65,7 +65,8 @@ public Process LaunchEmulator (string avdName, bool coldBoot = false, List (() => runner.LaunchEmulator (" ")); } - // --- BootEmulatorAsync tests (ported from dotnet/android BootAndroidEmulatorTests) --- - [Test] public async Task AlreadyOnlineDevice_PassesThrough () { @@ -295,8 +293,6 @@ public async Task MultipleEmulators_FindsCorrectAvd () Assert.AreEqual ("emulator-5556", result.Serial, "Should find the correct AVD among multiple emulators"); } - // --- Tests ported from dotnet/android BootAndroidEmulatorTests --- - [Test] public async Task AlreadyOnlinePhysicalDevice_PassesThrough () { @@ -480,18 +476,15 @@ public void BootEmulatorAsync_EmptyDeviceName_Throws () runner.BootEmulatorAsync ("", mockAdb)); } - // --- Helpers --- - [Test] [Platform ("Linux,MacOsX")] public void LaunchEmulator_SurvivesSigint () { - // Verify that the emulator process launched by LaunchEmulator ignores - // SIGINT (Ctrl+C) so it is not killed when the parent receives the signal. var (tempDir, emuPath) = CreateFakeEmulatorSdk (); + Process? process = null; try { var runner = new EmulatorRunner (emuPath); - using var process = runner.LaunchEmulator ("TestAVD"); + process = runner.LaunchEmulator ("TestAVD"); Assert.IsFalse (process.HasExited, "Process should be running after launch"); @@ -499,16 +492,16 @@ public void LaunchEmulator_SurvivesSigint () var killPsi = ProcessUtils.CreateProcessStartInfo ("kill", "-INT", process.Id.ToString ()); using var kill = new Process { StartInfo = killPsi }; kill.Start (); - kill.WaitForExit (5000); + Assert.IsTrue (kill.WaitForExit (5000), "kill command should exit promptly"); + Assert.AreEqual (0, kill.ExitCode, "kill -INT should succeed"); // Give the signal a moment to be delivered Thread.Sleep (500); Assert.IsFalse (process.HasExited, "Emulator process should survive SIGINT"); - - process.Kill (); - process.WaitForExit (5000); } finally { + try { process?.Kill (); process?.WaitForExit (5000); } catch { } + process?.Dispose (); Directory.Delete (tempDir, true); } } @@ -517,8 +510,6 @@ public void LaunchEmulator_SurvivesSigint () [Platform ("Linux,MacOsX")] public void ShellQuote_EscapesSingleQuotes () { - // Verify that paths with special characters are handled correctly - // by launching a fake emulator with a path containing a single quote. var tempDir = Path.Combine (Path.GetTempPath (), $"emu-quote-test-{Path.GetRandomFileName ()}"); var emulatorDir = Path.Combine (tempDir, "emu'dir"); Directory.CreateDirectory (emulatorDir); @@ -528,18 +519,18 @@ public void ShellQuote_EscapesSingleQuotes () var psi = ProcessUtils.CreateProcessStartInfo ("chmod", "+x", emuPath); using (var chmod = new Process { StartInfo = psi }) { chmod.Start (); - chmod.WaitForExit (); + Assert.IsTrue (chmod.WaitForExit (5000), "chmod should exit promptly"); } + Process? process = null; try { var runner = new EmulatorRunner (emuPath); - using var process = runner.LaunchEmulator ("TestAVD"); + process = runner.LaunchEmulator ("TestAVD"); Assert.IsFalse (process.HasExited, "Process should start even with single-quote in path"); - - process.Kill (); - process.WaitForExit (5000); } finally { + try { process?.Kill (); process?.WaitForExit (5000); } catch { } + process?.Dispose (); Directory.Delete (tempDir, true); } }