diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorLaunchResult.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorLaunchResult.cs new file mode 100644 index 00000000..ec9571a8 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorLaunchResult.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Threading.Tasks; + +namespace Xamarin.Android.Tools; + +/// +/// Returned by with enriched launch information. +/// +public sealed class EmulatorLaunchResult +{ + public EmulatorLaunchResult (Process process, string logPath) + { + if (process is null) + throw new System.ArgumentNullException (nameof (process)); + if (logPath is null) + throw new System.ArgumentNullException (nameof (logPath)); + Process = process; + LogPath = logPath; + } + + /// The running emulator process. + public Process Process { get; } + + /// The OS process ID of the emulator process. + public int Pid => Process.Id; + + /// + /// The emulator console port (e.g., 5554). Populated either from the pre-assigned + /// -ports argument or once completes. + /// + public int? ConsolePort { get; internal set; } + + /// + /// The emulator ADB port (e.g., 5555). Populated either from the pre-assigned + /// -ports argument or once completes. + /// + public int? AdbPort { get; internal set; } + + /// + /// The ADB serial for this emulator (e.g., emulator-5554), derived from . + /// Returns null until is populated. + /// + public string? Serial => ConsolePort is int p ? $"emulator-{p}" : null; + + /// + /// The path to the emulator log file. Resolved at launch time from the -logfile + /// argument (if specified) or from the ANDROID_AVD_HOME / ANDROID_USER_HOME + /// environment variables, falling back to the AOSP default + /// (~/.android/avd/<name>.avd/emulator.log). + /// + public string LogPath { get; } + + /// + /// A that completes when the emulator has reported its console and ADB + /// port assignments via stdout/stderr. If ports were pre-assigned via -ports, this + /// task is already completed. + /// + internal Task PortsResolvedAsync { get; set; } = Task.CompletedTask; +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs index d2ab46f0..67535a95 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs @@ -341,6 +341,55 @@ internal static IEnumerable ExecutableFiles (string executable) yield return executable; } + /// + /// Starts a process without waiting for it to exit (fire-and-forget). Attaches async + /// line-by-line stdout/stderr callbacks, starts the process, and begins async reads. + /// The caller is responsible for the process lifetime. + /// + /// A whose is already configured. + /// Optional callback invoked for each line written to stdout. + /// Optional callback invoked for each line written to stderr. + /// Optional callback invoked with the exit code when the process exits. + /// Thrown when the process fails to start. + internal static void StartFireAndForget ( + Process process, + Action? onOutputLine, + Action? onErrorLine, + Action? onExited = null) + { + if (onOutputLine != null) { + process.OutputDataReceived += (_, e) => { + if (e.Data != null) + onOutputLine (e.Data); + }; + } + if (onErrorLine != null) { + process.ErrorDataReceived += (_, e) => { + if (e.Data != null) + onErrorLine (e.Data); + }; + } + + if (onExited != null) { + process.EnableRaisingEvents = true; + process.Exited += (_, _) => { + int exitCode; + try { exitCode = process.ExitCode; } catch { exitCode = -1; } + onExited (exitCode); + }; + } + + if (!process.Start ()) { + process.Dispose (); + throw new InvalidOperationException ($"Failed to start process '{process.StartInfo.FileName}'."); + } + + if (process.StartInfo.RedirectStandardOutput) + process.BeginOutputReadLine (); + if (process.StartInfo.RedirectStandardError) + process.BeginErrorReadLine (); + } + /// Checks if running as Administrator (Windows) or root (macOS/Linux). public static bool IsElevated () { diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt index dd3c96f1..0fce9e15 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -176,7 +176,16 @@ Xamarin.Android.Tools.EmulatorRunner Xamarin.Android.Tools.EmulatorRunner.BootEmulatorAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! -Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, System.Collections.Generic.List? additionalArgs = null) -> System.Diagnostics.Process! +Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, int? consolePort = null, int? adbPort = null, string? logFile = null, System.Collections.Generic.List? additionalArgs = null) -> Xamarin.Android.Tools.EmulatorLaunchResult! +Xamarin.Android.Tools.EmulatorRunner.LaunchEmulatorAsync(string! avdName, bool coldBoot = false, int? consolePort = null, int? adbPort = null, string? logFile = null, System.Collections.Generic.List? additionalArgs = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorLaunchResult +Xamarin.Android.Tools.EmulatorLaunchResult.EmulatorLaunchResult(System.Diagnostics.Process! process, string! logPath) -> void +Xamarin.Android.Tools.EmulatorLaunchResult.AdbPort.get -> int? +Xamarin.Android.Tools.EmulatorLaunchResult.ConsolePort.get -> int? +Xamarin.Android.Tools.EmulatorLaunchResult.LogPath.get -> string! +Xamarin.Android.Tools.EmulatorLaunchResult.Pid.get -> int +Xamarin.Android.Tools.EmulatorLaunchResult.Process.get -> System.Diagnostics.Process! +Xamarin.Android.Tools.EmulatorLaunchResult.Serial.get -> string? Xamarin.Android.Tools.AdbPortRule Xamarin.Android.Tools.AdbPortRule.AdbPortRule(Xamarin.Android.Tools.AdbPortSpec! Remote, Xamarin.Android.Tools.AdbPortSpec! Local) -> void Xamarin.Android.Tools.AdbPortRule.Local.get -> Xamarin.Android.Tools.AdbPortSpec! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index dd3c96f1..0fce9e15 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -176,7 +176,16 @@ Xamarin.Android.Tools.EmulatorRunner Xamarin.Android.Tools.EmulatorRunner.BootEmulatorAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! -Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, System.Collections.Generic.List? additionalArgs = null) -> System.Diagnostics.Process! +Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, int? consolePort = null, int? adbPort = null, string? logFile = null, System.Collections.Generic.List? additionalArgs = null) -> Xamarin.Android.Tools.EmulatorLaunchResult! +Xamarin.Android.Tools.EmulatorRunner.LaunchEmulatorAsync(string! avdName, bool coldBoot = false, int? consolePort = null, int? adbPort = null, string? logFile = null, System.Collections.Generic.List? additionalArgs = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorLaunchResult +Xamarin.Android.Tools.EmulatorLaunchResult.EmulatorLaunchResult(System.Diagnostics.Process! process, string! logPath) -> void +Xamarin.Android.Tools.EmulatorLaunchResult.AdbPort.get -> int? +Xamarin.Android.Tools.EmulatorLaunchResult.ConsolePort.get -> int? +Xamarin.Android.Tools.EmulatorLaunchResult.LogPath.get -> string! +Xamarin.Android.Tools.EmulatorLaunchResult.Pid.get -> int +Xamarin.Android.Tools.EmulatorLaunchResult.Process.get -> System.Diagnostics.Process! +Xamarin.Android.Tools.EmulatorLaunchResult.Serial.get -> string? Xamarin.Android.Tools.AdbPortRule Xamarin.Android.Tools.AdbPortRule.AdbPortRule(Xamarin.Android.Tools.AdbPortSpec! Remote, Xamarin.Android.Tools.AdbPortSpec! Local) -> void Xamarin.Android.Tools.AdbPortRule.Local.get -> Xamarin.Android.Tools.AdbPortSpec! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index eff26f57..054f62af 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -37,24 +37,71 @@ public EmulatorRunner (string emulatorPath, IDictionary? environ } /// - /// Launches an emulator process for the specified AVD and returns immediately. - /// The returned represents the running emulator — the caller - /// is responsible for managing its lifetime (e.g., killing it on shutdown). + /// Launches an emulator process for the specified AVD and returns immediately with enriched + /// launch information (process, PID, ports, serial, log path). + /// The caller is responsible for managing the process lifetime (e.g., killing it on shutdown). /// This method does not wait for the emulator to finish booting. /// To launch and wait until the device is fully booted, use instead. /// /// Name of the AVD to launch (as shown by emulator -list-avds). /// When true, forces a cold boot by passing -no-snapshot-load. + /// + /// Optional console port to pre-assign via -ports (typically an even number, e.g. 5554). + /// When specified the serial is known immediately; otherwise it is resolved by parsing stdout/stderr. + /// + /// + /// Optional ADB port to pair with . Defaults to + /// consolePort + 1 when is provided. + /// + /// + /// Optional path for the emulator log file, passed via -logfile. When null the + /// default AOSP path is resolved from ANDROID_AVD_HOME / ANDROID_USER_HOME. + /// /// Optional extra arguments to pass to the emulator command line. - /// The running the emulator. Stdout/stderr are redirected and forwarded to the logger. - public Process LaunchEmulator (string avdName, bool coldBoot = false, List? additionalArgs = null) + /// + /// An with the running process and launch details. + /// Await before reading + /// / + /// when ports were not pre-assigned. + /// + public EmulatorLaunchResult LaunchEmulator ( + string avdName, + bool coldBoot = false, + int? consolePort = null, + int? adbPort = null, + string? logFile = null, + List? additionalArgs = null) { if (string.IsNullOrWhiteSpace (avdName)) throw new ArgumentException ("AVD name must not be empty.", nameof (avdName)); + if (adbPort.HasValue && !consolePort.HasValue) + throw new ArgumentException ("adbPort requires consolePort to be specified.", nameof (adbPort)); var args = new List { "-avd", avdName }; if (coldBoot) args.Add ("-no-snapshot-load"); + + // Pre-assign ports when requested; the serial is then known before the process starts. + int? resolvedConsolePort = consolePort; + int? resolvedAdbPort = adbPort; + bool portsPreAssigned = consolePort.HasValue; + + if (consolePort.HasValue) { + resolvedAdbPort ??= consolePort.Value + 1; + args.Add ("-ports"); + args.Add ($"{consolePort.Value},{resolvedAdbPort.Value}"); + } + + // Resolve log path: use explicit override or compute from env vars. + string resolvedLogPath; + if (!string.IsNullOrWhiteSpace (logFile)) { + resolvedLogPath = logFile!; + args.Add ("-logfile"); + args.Add (logFile!); + } else { + resolvedLogPath = ResolveAvdLogPath (avdName); + } + if (additionalArgs != null) args.AddRange (additionalArgs); @@ -91,27 +138,117 @@ public Process LaunchEmulator (string avdName, bool coldBoot = false, List { - if (e.Data != null) - logger.Invoke (TraceLevel.Verbose, $"[emulator] {e.Data}"); - }; - process.ErrorDataReceived += (_, e) => { - if (e.Data != null) - logger.Invoke (TraceLevel.Warning, $"[emulator] {e.Data}"); + // When ports are not pre-assigned, parse stdout/stderr for the well-known boot lines + // that report the assigned ports. A TaskCompletionSource signals callers once both + // ports have been observed. + TaskCompletionSource? tcs = portsPreAssigned + ? null + : new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + + var result = new EmulatorLaunchResult (process, resolvedLogPath) { + ConsolePort = resolvedConsolePort, + AdbPort = resolvedAdbPort, }; + result.PortsResolvedAsync = tcs is { } activeTcs ? (Task)activeTcs.Task : Task.CompletedTask; + + ProcessUtils.StartFireAndForget ( + process, + onOutputLine: line => { + logger.Invoke (TraceLevel.Verbose, $"[emulator] {line}"); + if (tcs != null) + TryResolvePortsFromLine (line, result, tcs); + }, + onErrorLine: line => { + logger.Invoke (TraceLevel.Verbose, $"[emulator stderr] {line}"); + if (tcs != null) + TryResolvePortsFromLine (line, result, tcs); + }, + onExited: tcs != null ? exitCode => { + tcs.TrySetException (new InvalidOperationException ( + $"Emulator process exited (code {exitCode}) before port assignment lines were emitted.")); + } : null); + + return result; + } - if (!process.Start ()) { - process.Dispose (); - throw new InvalidOperationException ($"Failed to start emulator process '{emulatorPath}'."); + /// + /// Launches an emulator and waits until its console and ADB ports are resolved. + /// When is provided the ports are known immediately; + /// otherwise stdout/stderr is parsed for the emulator's port-announcement lines. + /// + public async Task LaunchEmulatorAsync ( + string avdName, + bool coldBoot = false, + int? consolePort = null, + int? adbPort = null, + string? logFile = null, + List? additionalArgs = null, + CancellationToken cancellationToken = default) + { + var result = LaunchEmulator (avdName, coldBoot, consolePort, adbPort, logFile, additionalArgs); + + using var registration = cancellationToken.Register (() => { + try { result.Process.Kill (); } catch { } + }); + + await result.PortsResolvedAsync.ConfigureAwait (false); + return result; + } + + /// + /// Parses a single emulator output line and, when the relevant port-assignment patterns are + /// found, updates and completes . + /// + /// + /// The emulator emits (on stdout or stderr): + /// emulator: Listening on port NNNN (console port) + /// emulator: ADB Server has started successfully on port NNNN (adb port) + /// These lines have been stable across emulator releases for years. + /// + internal static void TryResolvePortsFromLine (string line, EmulatorLaunchResult result, TaskCompletionSource tcs) + { + const string listeningPrefix = "emulator: Listening on port "; + const string adbPrefix = "emulator: ADB Server has started successfully on port "; + + if (line.StartsWith (listeningPrefix, StringComparison.Ordinal)) { + if (int.TryParse (line.Substring (listeningPrefix.Length).Trim (), out var port)) + result.ConsolePort = port; + } else if (line.StartsWith (adbPrefix, StringComparison.Ordinal)) { + if (int.TryParse (line.Substring (adbPrefix.Length).Trim (), out var port)) + result.AdbPort = port; } - // Drain redirected streams asynchronously to prevent pipe buffer deadlocks - process.BeginOutputReadLine (); - process.BeginErrorReadLine (); + if (result.ConsolePort.HasValue && result.AdbPort.HasValue) + tcs.TrySetResult (true); + } + + /// + /// Resolves the default emulator log path for the given AVD name, respecting the + /// ANDROID_AVD_HOME and ANDROID_USER_HOME environment variables + /// (including any overrides set on this instance). + /// Falls back to the AOSP convention: ~/.android/avd/<name>.avd/emulator.log. + /// + internal string ResolveAvdLogPath (string avdName) + { + var avdDirName = avdName + ".avd"; + + var avdHome = GetEffectiveEnvVar (EnvironmentVariableNames.AndroidAvdHome); + if (!string.IsNullOrEmpty (avdHome)) + return Path.Combine (avdHome, avdDirName, "emulator.log"); + + var userHome = GetEffectiveEnvVar (EnvironmentVariableNames.AndroidUserHome); + if (!string.IsNullOrEmpty (userHome)) + return Path.Combine (userHome, "avd", avdDirName, "emulator.log"); - return process; + var home = Environment.GetFolderPath (Environment.SpecialFolder.UserProfile); + return Path.Combine (home, ".android", "avd", avdDirName, "emulator.log"); + } + + string? GetEffectiveEnvVar (string name) + { + if (environmentVariables != null && environmentVariables.TryGetValue (name, out var val)) + return val; + return Environment.GetEnvironmentVariable (name); } public async Task> ListAvdNamesAsync (CancellationToken cancellationToken = default) @@ -210,9 +347,9 @@ public async Task BootEmulatorAsync ( // Phase 3: Launch the emulator logger.Invoke (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'..."); - Process emulatorProcess; + EmulatorLaunchResult launchResult; try { - emulatorProcess = LaunchEmulator (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs); + launchResult = LaunchEmulator (deviceOrAvdName, options.ColdBoot, additionalArgs: options.AdditionalArgs); } catch (Exception ex) { return new EmulatorBootResult { Success = false, @@ -231,7 +368,7 @@ public async Task BootEmulatorAsync ( // as immediate failures; exit code 0 means we continue polling. // // Dispose the Process handle when done — the emulator process keeps running. - using (emulatorProcess) { + using (launchResult.Process) { try { string? newSerial = null; bool processExitedWithZero = false; @@ -242,12 +379,12 @@ public async Task BootEmulatorAsync ( // Guard against InvalidOperationException in case no OS process // is associated with the object (e.g. broken emulator binary). try { - if (emulatorProcess.HasExited && !processExitedWithZero) { - if (emulatorProcess.ExitCode != 0) { + if (launchResult.Process.HasExited && !processExitedWithZero) { + if (launchResult.Process.ExitCode != 0) { 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 {launchResult.Process.ExitCode} before becoming available.", }; } // Exit code 0: emulator likely forked (common on macOS). @@ -272,14 +409,14 @@ public async Task BootEmulatorAsync ( logger.Invoke (TraceLevel.Info, $"Emulator appeared as '{newSerial}', waiting for full boot..."); return await WaitForFullBootAsync (adbRunner, newSerial, options, timeoutCts.Token).ConfigureAwait (false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - TryKillProcess (emulatorProcess); + TryKillProcess (launchResult.Process); return new EmulatorBootResult { Success = false, ErrorKind = EmulatorBootErrorKind.Timeout, ErrorMessage = $"Timed out waiting for emulator '{deviceOrAvdName}' to boot within {options.BootTimeout.TotalSeconds}s.", }; } catch { - TryKillProcess (emulatorProcess); + TryKillProcess (launchResult.Process); throw; } } @@ -337,4 +474,3 @@ async Task WaitForFullBootAsync ( /// 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 c94da2ec..1e9e1d78 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -97,6 +97,291 @@ public void LaunchEmulator_ThrowsOnWhitespaceAvdName () Assert.Throws (() => runner.LaunchEmulator (" ")); } + [Test] + public void LaunchEmulator_PreAssignedPorts_SerialKnownImmediately () + { + var (tempDir, emuPath) = CreateFakeEmulatorSdk (); + EmulatorLaunchResult? result = null; + try { + var runner = new EmulatorRunner (emuPath); + result = runner.LaunchEmulator ("Test_AVD", consolePort: 5554); + + Assert.AreEqual (5554, result.ConsolePort, "ConsolePort should be the pre-assigned value"); + Assert.AreEqual (5555, result.AdbPort, "AdbPort should default to consolePort + 1"); + Assert.AreEqual ("emulator-5554", result.Serial, "Serial should be derived from ConsolePort"); + Assert.IsNotNull (result.Process); + Assert.IsFalse (string.IsNullOrEmpty (result.LogPath), "LogPath should be non-empty"); + } finally { + TryKillProcess (result?.Process); + Directory.Delete (tempDir, true); + } + } + + [Test] + public void LaunchEmulator_PreAssignedPortsExplicitAdb_BothPortsSet () + { + var (tempDir, emuPath) = CreateFakeEmulatorSdk (); + EmulatorLaunchResult? result = null; + try { + var runner = new EmulatorRunner (emuPath); + result = runner.LaunchEmulator ("Test_AVD", consolePort: 5560, adbPort: 5570); + + Assert.AreEqual (5560, result.ConsolePort); + Assert.AreEqual (5570, result.AdbPort); + Assert.AreEqual ("emulator-5560", result.Serial); + } finally { + TryKillProcess (result?.Process); + Directory.Delete (tempDir, true); + } + } + + [Test] + public void LaunchEmulator_NoPorts_SerialNullUntilResolved () + { + var (tempDir, emuPath) = CreateFakeEmulatorSdk (); + EmulatorLaunchResult? result = null; + try { + var runner = new EmulatorRunner (emuPath); + result = runner.LaunchEmulator ("Test_AVD"); + + Assert.IsNull (result.ConsolePort, "ConsolePort should be null until resolved via stdout"); + Assert.IsNull (result.Serial, "Serial should be null until ports are resolved"); + } finally { + TryKillProcess (result?.Process); + Directory.Delete (tempDir, true); + } + } + + [Test] + public async Task LaunchEmulatorAsync_ResolvesPortsFromStdout () + { + var tempDir = Path.Combine (Path.GetTempPath (), $"emu-ports-test-{Path.GetRandomFileName ()}"); + var emuDir = Path.Combine (tempDir, "emulator"); + Directory.CreateDirectory (emuDir); + var emuName = OS.IsWindows ? "emulator.bat" : "emulator"; + var emuPath = Path.Combine (emuDir, emuName); + + if (OS.IsWindows) { + File.WriteAllText (emuPath, + "@echo off\r\n" + + "echo emulator: Listening on port 5558\r\n" + + "echo emulator: ADB Server has started successfully on port 5559\r\n" + + "timeout /t 5 /nobreak >nul\r\n"); + } else { + File.WriteAllText (emuPath, + "#!/bin/sh\n" + + "echo 'emulator: Listening on port 5558'\n" + + "echo 'emulator: ADB Server has started successfully on port 5559'\n" + + "sleep 5\n"); + var chmod = ProcessUtils.CreateProcessStartInfo ("chmod", "+x", emuPath); + using var p = new Process { StartInfo = chmod }; + p.Start (); + p.WaitForExit (); + } + + EmulatorLaunchResult? result = null; + try { + var runner = new EmulatorRunner (emuPath); + using var cts = new CancellationTokenSource (TimeSpan.FromSeconds (10)); + result = await runner.LaunchEmulatorAsync ("Test_AVD", cancellationToken: cts.Token); + + Assert.AreEqual (5558, result.ConsolePort); + Assert.AreEqual (5559, result.AdbPort); + Assert.AreEqual ("emulator-5558", result.Serial); + } finally { + TryKillProcess (result?.Process); + Directory.Delete (tempDir, true); + } + } + + [Test] + public void LaunchEmulator_ExplicitLogFile_LogPathSet () + { + var (tempDir, emuPath) = CreateFakeEmulatorSdk (); + var logPath = Path.Combine (tempDir, "my-emulator.log"); + EmulatorLaunchResult? result = null; + try { + var runner = new EmulatorRunner (emuPath); + result = runner.LaunchEmulator ("Test_AVD", logFile: logPath); + + Assert.AreEqual (logPath, result.LogPath); + } finally { + TryKillProcess (result?.Process); + Directory.Delete (tempDir, true); + } + } + + [Test] + public void LaunchEmulator_DefaultLogPath_ContainsAvdName () + { + var runner = new EmulatorRunner ("/fake/emulator"); + var logPath = runner.ResolveAvdLogPath ("My_AVD"); + + StringAssert.Contains ("My_AVD.avd", logPath); + StringAssert.EndsWith ("emulator.log", logPath); + } + + [Test] + public void ResolveAvdLogPath_AndroidAvdHome_Overrides () + { + var env = new Dictionary { ["ANDROID_AVD_HOME"] = "/custom/avd" }; + var runner = new EmulatorRunner ("/fake/emulator", environmentVariables: env); + var logPath = runner.ResolveAvdLogPath ("My_AVD"); + + Assert.AreEqual (Path.Combine ("/custom/avd", "My_AVD.avd", "emulator.log"), logPath); + } + + [Test] + public void ResolveAvdLogPath_AndroidUserHome_UsedWhenNoAvdHome () + { + var env = new Dictionary { ["ANDROID_USER_HOME"] = "/custom/user" }; + var runner = new EmulatorRunner ("/fake/emulator", environmentVariables: env); + var logPath = runner.ResolveAvdLogPath ("My_AVD"); + + Assert.AreEqual (Path.Combine ("/custom/user", "avd", "My_AVD.avd", "emulator.log"), logPath); + } + + [Test] + public void TryResolvePortsFromLine_ConsolePort_Parsed () + { + var result = new EmulatorLaunchResult (new Process (), ""); + var tcs = new TaskCompletionSource (); + + EmulatorRunner.TryResolvePortsFromLine ("emulator: Listening on port 5554", result, tcs); + + Assert.AreEqual (5554, result.ConsolePort); + Assert.IsFalse (tcs.Task.IsCompleted, "Task should not complete until ADB port is also found"); + } + + [Test] + public void TryResolvePortsFromLine_AdbPort_Parsed () + { + var result = new EmulatorLaunchResult (new Process (), ""); + var tcs = new TaskCompletionSource (); + + EmulatorRunner.TryResolvePortsFromLine ("emulator: ADB Server has started successfully on port 5555", result, tcs); + + Assert.AreEqual (5555, result.AdbPort); + Assert.IsFalse (tcs.Task.IsCompleted, "Task should not complete until console port is also found"); + } + + [Test] + public void TryResolvePortsFromLine_BothPorts_CompletesTask () + { + var result = new EmulatorLaunchResult (new Process (), ""); + var tcs = new TaskCompletionSource (); + + EmulatorRunner.TryResolvePortsFromLine ("emulator: Listening on port 5556", result, tcs); + EmulatorRunner.TryResolvePortsFromLine ("emulator: ADB Server has started successfully on port 5557", result, tcs); + + Assert.AreEqual (5556, result.ConsolePort); + Assert.AreEqual (5557, result.AdbPort); + Assert.AreEqual ("emulator-5556", result.Serial); + Assert.IsTrue (tcs.Task.IsCompleted, "Task should complete when both ports are found"); + } + + [Test] + public void TryResolvePortsFromLine_UnrelatedLine_NoEffect () + { + var result = new EmulatorLaunchResult (new Process (), ""); + var tcs = new TaskCompletionSource (); + + EmulatorRunner.TryResolvePortsFromLine ("emulator: cold boot", result, tcs); + + Assert.IsNull (result.ConsolePort); + Assert.IsNull (result.AdbPort); + Assert.IsFalse (tcs.Task.IsCompleted); + } + + [Test] + [Platform ("Linux,MacOsX")] + public void LaunchEmulator_SurvivesSigint () + { + var (tempDir, emuPath) = CreateFakeEmulatorSdk (); + EmulatorLaunchResult? result = null; + try { + var runner = new EmulatorRunner (emuPath); + result = runner.LaunchEmulator ("TestAVD"); + + Assert.IsFalse (result.Process.HasExited, "Process should be running after launch"); + + // Send SIGINT to the emulator process + var killPsi = ProcessUtils.CreateProcessStartInfo ("kill", "-INT", result.Process.Id.ToString ()); + using var kill = new Process { StartInfo = killPsi }; + kill.Start (); + 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 (result.Process.HasExited, "Emulator process should survive SIGINT"); + } finally { + TryKillProcess (result?.Process); + Directory.Delete (tempDir, true); + } + } + + [Test] + public async Task InvalidEmulatorBinary_ReturnsLaunchFailed () + { + var (tempDir, emuPath) = CreateFakeEmulatorSdk (); + + // Overwrite with a script that exits immediately with error code 1 + if (OS.IsWindows) { + File.WriteAllText (emuPath, "@echo off\r\nexit /b 1\r\n"); + } else { + File.WriteAllText (emuPath, "#!/bin/sh\nexit 1\n"); + } + + try { + var devices = new List (); + var mockAdb = new MockAdbRunner (devices); + + var runner = new EmulatorRunner (emuPath); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromSeconds (5), + PollInterval = TimeSpan.FromMilliseconds (50), + }; + + var result = await runner.BootEmulatorAsync ("Test_AVD", mockAdb, options); + + Assert.IsFalse (result.Success); + Assert.AreEqual (EmulatorBootErrorKind.LaunchFailed, result.ErrorKind); + Assert.That (result.ErrorMessage, Does.Contain ("exited with code")); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + [Platform ("Linux,MacOsX")] + public void ShellQuote_EscapesSingleQuotes () + { + 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 (); + Assert.IsTrue (chmod.WaitForExit (5000), "chmod should exit promptly"); + } + + EmulatorLaunchResult? result = null; + try { + var runner = new EmulatorRunner (emuPath); + result = runner.LaunchEmulator ("TestAVD"); + + Assert.IsFalse (result.Process.HasExited, "Process should start even with single-quote in path"); + } finally { + TryKillProcess (result?.Process); + Directory.Delete (tempDir, true); + } + } + [Test] public async Task AlreadyOnlineDevice_PassesThrough () { @@ -166,7 +451,6 @@ public async Task BootEmulator_AppearsAfterPolling () }; var (tempDir, emuPath) = CreateFakeEmulatorSdk (); - Process? emulatorProcess = null; try { var runner = new EmulatorRunner (emuPath); var options = new EmulatorBootOptions { @@ -180,12 +464,6 @@ public async Task BootEmulator_AppearsAfterPolling () Assert.AreEqual ("emulator-5554", result.Serial); Assert.IsTrue (pollCount >= 2); } finally { - // Kill any emulator process spawned by the test - try { - emulatorProcess = FindEmulatorProcess (emuPath); - emulatorProcess?.Kill (); - emulatorProcess?.WaitForExit (1000); - } catch { } Directory.Delete (tempDir, true); } } @@ -328,9 +606,9 @@ public async Task AdditionalArgs_PassedToLaunchEmulator () // Rewrite the fake emulator to log its arguments if (OS.IsWindows) { - File.WriteAllText (emuPath, $"@echo off\r\necho %* > \"{argsLogPath}\"\r\nping -n 60 127.0.0.1 >nul\r\n"); + File.WriteAllText (emuPath, $"@echo off\r\necho %* > \"{argsLogPath}\"\r\ntimeout /t 3 /nobreak >nul\r\n"); } else { - File.WriteAllText (emuPath, $"#!/bin/sh\necho \"$@\" > \"{argsLogPath}\"\nsleep 60\n"); + File.WriteAllText (emuPath, $"#!/bin/sh\necho \"$@\" > \"{argsLogPath}\"\nsleep 3\n"); } try { @@ -362,12 +640,6 @@ public async Task AdditionalArgs_PassedToLaunchEmulator () Assert.That (logged, Does.Contain ("Test_AVD"), "Should contain AVD name"); } } finally { - // Clean up any spawned processes - try { - foreach (var p in Process.GetProcessesByName ("sleep")) { - try { p.Kill (); p.WaitForExit (1000); } catch { } - } - } catch { } Directory.Delete (tempDir, true); } } @@ -405,11 +677,6 @@ public async Task CancellationToken_AbortsBoot () "Cancellation should abort within a few seconds, not wait for full timeout"); } } finally { - try { - foreach (var p in Process.GetProcessesByName ("sleep")) { - try { p.Kill (); p.WaitForExit (1000); } catch { } - } - } catch { } Directory.Delete (tempDir, true); } } @@ -422,9 +689,9 @@ public async Task ColdBoot_PassesNoSnapshotLoad () var argsLogPath = Path.Combine (tempDir, "args.log"); if (OS.IsWindows) { - File.WriteAllText (emuPath, $"@echo off\r\necho %* > \"{argsLogPath}\"\r\nping -n 60 127.0.0.1 >nul\r\n"); + File.WriteAllText (emuPath, $"@echo off\r\necho %* > \"{argsLogPath}\"\r\ntimeout /t 3 /nobreak >nul\r\n"); } else { - File.WriteAllText (emuPath, $"#!/bin/sh\necho \"$@\" > \"{argsLogPath}\"\nsleep 60\n"); + File.WriteAllText (emuPath, $"#!/bin/sh\necho \"$@\" > \"{argsLogPath}\"\nsleep 3\n"); } try { @@ -448,11 +715,6 @@ public async Task ColdBoot_PassesNoSnapshotLoad () Assert.That (logged, Does.Contain ("-no-snapshot-load"), "ColdBoot should pass -no-snapshot-load"); } } finally { - try { - foreach (var p in Process.GetProcessesByName ("sleep")) { - try { p.Kill (); p.WaitForExit (1000); } catch { } - } - } catch { } Directory.Delete (tempDir, true); } } @@ -476,97 +738,6 @@ public void BootEmulatorAsync_EmptyDeviceName_Throws () runner.BootEmulatorAsync ("", mockAdb)); } - [Test] - [Platform ("Linux,MacOsX")] - public void LaunchEmulator_SurvivesSigint () - { - var (tempDir, emuPath) = CreateFakeEmulatorSdk (); - Process? process = null; - try { - var runner = new EmulatorRunner (emuPath); - 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 (); - 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"); - } finally { - try { process?.Kill (); process?.WaitForExit (5000); } catch { } - process?.Dispose (); - Directory.Delete (tempDir, true); - } - } - - [Test] - public async Task InvalidEmulatorBinary_ReturnsLaunchFailed () - { - var (tempDir, emuPath) = CreateFakeEmulatorSdk (); - - // Overwrite with a script that exits immediately with error code 1 - if (OS.IsWindows) { - File.WriteAllText (emuPath, "@echo off\r\nexit /b 1\r\n"); - } else { - File.WriteAllText (emuPath, "#!/bin/sh\nexit 1\n"); - } - - try { - var devices = new List (); - var mockAdb = new MockAdbRunner (devices); - - var runner = new EmulatorRunner (emuPath); - var options = new EmulatorBootOptions { - BootTimeout = TimeSpan.FromSeconds (5), - PollInterval = TimeSpan.FromMilliseconds (50), - }; - - var result = await runner.BootEmulatorAsync ("Test_AVD", mockAdb, options); - - Assert.IsFalse (result.Success); - Assert.AreEqual (EmulatorBootErrorKind.LaunchFailed, result.ErrorKind); - Assert.That (result.ErrorMessage, Does.Contain ("exited with code")); - } finally { - Directory.Delete (tempDir, true); - } - } - - [Test] - [Platform ("Linux,MacOsX")] - public void ShellQuote_EscapesSingleQuotes () - { - 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 (); - Assert.IsTrue (chmod.WaitForExit (5000), "chmod should exit promptly"); - } - - Process? process = null; - try { - var runner = new EmulatorRunner (emuPath); - process = runner.LaunchEmulator ("TestAVD"); - - Assert.IsFalse (process.HasExited, "Process should start even with single-quote in path"); - } finally { - try { process?.Kill (); process?.WaitForExit (5000); } catch { } - process?.Dispose (); - Directory.Delete (tempDir, true); - } - } - // --- Helpers --- static (string tempDir, string emulatorPath) CreateFakeEmulatorSdk () @@ -578,9 +749,9 @@ public void ShellQuote_EscapesSingleQuotes () var emuName = OS.IsWindows ? "emulator.bat" : "emulator"; var emuPath = Path.Combine (emulatorDir, emuName); if (OS.IsWindows) { - File.WriteAllText (emuPath, "@echo off\r\nping -n 60 127.0.0.1 >nul\r\n"); + File.WriteAllText (emuPath, "@echo off\r\ntimeout /t 5 /nobreak >nul\r\n"); } else { - File.WriteAllText (emuPath, "#!/bin/sh\nsleep 60\n"); + File.WriteAllText (emuPath, "#!/bin/sh\nsleep 5\n"); var psi = ProcessUtils.CreateProcessStartInfo ("chmod", "+x", emuPath); using var chmod = new Process { StartInfo = psi }; chmod.Start (); @@ -590,18 +761,13 @@ public void ShellQuote_EscapesSingleQuotes () return (tempDir, emuPath); } - static Process? FindEmulatorProcess (string emuPath) + static void TryKillProcess (Process? process) { - // Best-effort: find the process by matching the command line - try { - foreach (var p in Process.GetProcessesByName ("emulator")) { - return p; - } - foreach (var p in Process.GetProcessesByName ("sleep")) { - return p; - } - } catch { } - return null; + if (process is null) + return; + try { process.Kill (true); } catch { } + try { process.WaitForExit (1000); } catch { } + process.Dispose (); } ///