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 ();
}
///