Skip to content

Commit 5165523

Browse files
rmarinhoCopilot
andauthored
[AdbRunner] Add ADB forward port management (#351)
(follow-up to #305) Adds the symmetric forward-port pair to the reverse-port methods that landed in #305. Same `AdbPortSpec` / `AdbPortRule` / `AdbProtocol` types — just four new methods. | Method | adb command | |-------------------------------------------------|--------------------------------------------| | ForwardPortAsync(serial, local, remote) | adb -s <serial> forward <local> <remote> | | RemoveForwardPortAsync(serial, local) | adb -s <serial> forward --remove <local> | | RemoveAllForwardPortsAsync(serial) | adb -s <serial> forward --remove-all | | ListForwardPortsAsync(serial) | adb forward --list (filtered by serial) | `adb forward` and `adb reverse` are not interchangeable — they connect opposite directions. `forward` is host->device (the IDE/harness reaches a service running on the device) and is the path used for JDWP debugger attach (`forward tcp:N jdwp:<pid>`), perf-tracing endpoints exposed by the runtime, and host-side DevFlow agent connect when the agent listens on a device port. Output-format note for `--list`: `adb forward --list` emits one line per rule across all devices in the form `<serial> <local> <remote>` (different from `(reverse) <remote> <local>`). `ListForwardPortsAsync` uses the unscoped `adb forward --list` and filters to the requested serial in `ParseForwardListOutput`. Serial match is case-sensitive (matches adb). - 12 new parser tests in `ParseForwardListOutput_*` mirroring the reverse parser tests (single rule, multiple rules, serial filtering, empty output, malformed lines, non-tcp specs, Windows line endings, tab separation, case sensitivity). - 7 new parameter-validation tests covering empty serial / null spec for the four new public methods. - VS Code MAUI extension ServiceHub->CLI migration (`forwardPort` in `MauiAndroidPlatform.ts` — debugger configurations, perf tooling). - MAUI DevTools CLI (dotnet/maui-labs#197) — `maui android port forward` group, sibling of the existing `reverse` surface. - Visual Studio `ClientTools.Platform` — same paths that drive reverse today. Discussed in #305 (comment) ### Address review feedback: capture stdout in ThrowIfFailed, parser asymmetry comment, drop null!, remove test region dividers - ForwardPortAsync/RemoveForwardPortAsync/RemoveAllForwardPortsAsync now capture stdout and pass it to ProcessUtils.ThrowIfFailed (matches repo convention; adb sometimes writes errors to stdout). - Added <remarks> block on ParseForwardListOutput calling out the field-order asymmetry vs ParseReverseListOutput (forward: serial local remote; reverse: (reverse) remote local). - Replaced '(AdbPortSpec) null!' with '(AdbPortSpec) null' in 3 forward-port test sites to match reverse-test convention and repo no-null-forgiving rule. - Removed all '// --- ... ---' region-like divider comments in AdbRunnerTests.cs (per jonathanpeppers feedback in PR #351). ### Fix RemoveAllForwardPortsAsync to honour per-serial scope The underlying 'adb forward --remove-all' (and the wire-protocol equivalent 'host-serial:<serial>:killforward-all') is daemon-global -- the '-s <serial>' flag does not scope it. The previous implementation would silently remove forwards for every connected device despite the method's per-device API contract. Reimplement by listing forwards for the given serial via ListForwardPortsAsync and removing them individually via RemoveForwardPortAsync. Update the XML docs to describe the actual behaviour. Add two new tests using a recording subclass of AdbRunner that overrides ListForwardPortsAsync and RemoveForwardPortAsync to verify (1) only ports for the requested serial are removed, and (2) an empty listing is a no-op. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 06a0867 commit 5165523

4 files changed

Lines changed: 427 additions & 22 deletions

File tree

src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,10 @@ virtual Xamarin.Android.Tools.AdbRunner.ListReversePortsAsync(string! serial, Sy
199199
virtual Xamarin.Android.Tools.AdbRunner.RemoveAllReversePortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
200200
virtual Xamarin.Android.Tools.AdbRunner.RemoveReversePortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! remote, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
201201
virtual Xamarin.Android.Tools.AdbRunner.ReversePortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! remote, Xamarin.Android.Tools.AdbPortSpec! local, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
202+
virtual Xamarin.Android.Tools.AdbRunner.ForwardPortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! local, Xamarin.Android.Tools.AdbPortSpec! remote, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
203+
virtual Xamarin.Android.Tools.AdbRunner.ListForwardPortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AdbPortRule!>!>!
204+
virtual Xamarin.Android.Tools.AdbRunner.RemoveAllForwardPortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
205+
virtual Xamarin.Android.Tools.AdbRunner.RemoveForwardPortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! local, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
202206
Xamarin.Android.Tools.AvdManagerRunner.ListDeviceProfilesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AvdDeviceProfile!>!>!
203207
Xamarin.Android.Tools.AvdDeviceProfile
204208
Xamarin.Android.Tools.AvdDeviceProfile.AvdDeviceProfile(string! Id) -> void

src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,10 @@ virtual Xamarin.Android.Tools.AdbRunner.ListReversePortsAsync(string! serial, Sy
199199
virtual Xamarin.Android.Tools.AdbRunner.RemoveAllReversePortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
200200
virtual Xamarin.Android.Tools.AdbRunner.RemoveReversePortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! remote, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
201201
virtual Xamarin.Android.Tools.AdbRunner.ReversePortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! remote, Xamarin.Android.Tools.AdbPortSpec! local, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
202+
virtual Xamarin.Android.Tools.AdbRunner.ForwardPortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! local, Xamarin.Android.Tools.AdbPortSpec! remote, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
203+
virtual Xamarin.Android.Tools.AdbRunner.ListForwardPortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AdbPortRule!>!>!
204+
virtual Xamarin.Android.Tools.AdbRunner.RemoveAllForwardPortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
205+
virtual Xamarin.Android.Tools.AdbRunner.RemoveForwardPortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! local, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
202206
Xamarin.Android.Tools.AvdManagerRunner.ListDeviceProfilesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AvdDeviceProfile!>!>!
203207
Xamarin.Android.Tools.AvdDeviceProfile
204208
Xamarin.Android.Tools.AvdDeviceProfile.AvdDeviceProfile(string! Id) -> void

src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,145 @@ internal static IReadOnlyList<AdbPortRule> ParseReverseListOutput (IEnumerable<s
355355
return rules;
356356
}
357357

358+
/// <summary>
359+
/// Sets up forward port forwarding via 'adb -s &lt;serial&gt; forward &lt;local&gt; &lt;remote&gt;'.
360+
/// The host-side &lt;local&gt; socket is forwarded to the device-side &lt;remote&gt; socket,
361+
/// the symmetric pair to <see cref="ReversePortAsync"/>.
362+
/// </summary>
363+
/// <param name="serial">Device serial number.</param>
364+
/// <param name="local">Local (host-side) port spec.</param>
365+
/// <param name="remote">Remote (device-side) port spec.</param>
366+
/// <param name="cancellationToken">Cancellation token.</param>
367+
public virtual async Task ForwardPortAsync (string serial, AdbPortSpec local, AdbPortSpec remote, CancellationToken cancellationToken = default)
368+
{
369+
if (string.IsNullOrWhiteSpace (serial))
370+
throw new ArgumentException ("Serial must not be empty.", nameof (serial));
371+
if (local is null)
372+
throw new ArgumentNullException (nameof (local));
373+
if (remote is null)
374+
throw new ArgumentNullException (nameof (remote));
375+
if (local.Port <= 0 || local.Port > 65535)
376+
throw new ArgumentOutOfRangeException (nameof (local), local.Port, "Port must be between 1 and 65535.");
377+
if (remote.Port <= 0 || remote.Port > 65535)
378+
throw new ArgumentOutOfRangeException (nameof (remote), remote.Port, "Port must be between 1 and 65535.");
379+
380+
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "forward", local.ToSocketSpec (), remote.ToSocketSpec ());
381+
using var stdout = new StringWriter ();
382+
using var stderr = new StringWriter ();
383+
var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
384+
ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} forward {local} {remote}", stderr, stdout);
385+
}
386+
387+
/// <summary>
388+
/// Removes a specific forward port forwarding rule via
389+
/// 'adb -s &lt;serial&gt; forward --remove &lt;local&gt;'.
390+
/// </summary>
391+
/// <param name="serial">Device serial number.</param>
392+
/// <param name="local">Local (host-side) port spec to remove.</param>
393+
/// <param name="cancellationToken">Cancellation token.</param>
394+
public virtual async Task RemoveForwardPortAsync (string serial, AdbPortSpec local, CancellationToken cancellationToken = default)
395+
{
396+
if (string.IsNullOrWhiteSpace (serial))
397+
throw new ArgumentException ("Serial must not be empty.", nameof (serial));
398+
if (local is null)
399+
throw new ArgumentNullException (nameof (local));
400+
if (local.Port <= 0 || local.Port > 65535)
401+
throw new ArgumentOutOfRangeException (nameof (local), local.Port, "Port must be between 1 and 65535.");
402+
403+
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "forward", "--remove", local.ToSocketSpec ());
404+
using var stdout = new StringWriter ();
405+
using var stderr = new StringWriter ();
406+
var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
407+
ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} forward --remove {local}", stderr, stdout);
408+
}
409+
410+
/// <summary>
411+
/// Removes all forward port forwarding rules for the specified device.
412+
/// </summary>
413+
/// <remarks>
414+
/// The underlying <c>adb forward --remove-all</c> command (and its wire-protocol
415+
/// equivalent <c>host-serial:&lt;serial&gt;:killforward-all</c>) operates globally on the
416+
/// adb daemon — the <c>-s &lt;serial&gt;</c> flag does not scope it, and calling it
417+
/// would remove forwards for every connected device. To honour the per-device
418+
/// contract of this method, we list the forwards for <paramref name="serial"/>
419+
/// via <see cref="ListForwardPortsAsync"/> and remove them individually via
420+
/// <see cref="RemoveForwardPortAsync"/>.
421+
/// </remarks>
422+
public virtual async Task RemoveAllForwardPortsAsync (string serial, CancellationToken cancellationToken = default)
423+
{
424+
if (string.IsNullOrWhiteSpace (serial))
425+
throw new ArgumentException ("Serial must not be empty.", nameof (serial));
426+
427+
var rules = await ListForwardPortsAsync (serial, cancellationToken).ConfigureAwait (false);
428+
foreach (var rule in rules) {
429+
cancellationToken.ThrowIfCancellationRequested ();
430+
await RemoveForwardPortAsync (serial, rule.Local, cancellationToken).ConfigureAwait (false);
431+
}
432+
}
433+
434+
/// <summary>
435+
/// Lists active forward port forwarding rules for the specified device via
436+
/// 'adb forward --list'.
437+
/// The underlying command always lists rules across all devices, so the
438+
/// result is filtered to entries matching <paramref name="serial"/>.
439+
/// </summary>
440+
public virtual async Task<IReadOnlyList<AdbPortRule>> ListForwardPortsAsync (string serial, CancellationToken cancellationToken = default)
441+
{
442+
if (string.IsNullOrWhiteSpace (serial))
443+
throw new ArgumentException ("Serial must not be empty.", nameof (serial));
444+
445+
using var stdout = new StringWriter ();
446+
using var stderr = new StringWriter ();
447+
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "forward", "--list");
448+
var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
449+
ProcessUtils.ThrowIfFailed (exitCode, $"adb forward --list", stderr, stdout);
450+
451+
return ParseForwardListOutput (stdout.ToString ().Split ('\n'), serial);
452+
}
453+
454+
/// <summary>
455+
/// Parses the output of 'adb forward --list'.
456+
/// Each line is "&lt;serial&gt; &lt;local&gt; &lt;remote&gt;", e.g. "emulator-5554 tcp:5000 tcp:6000".
457+
/// Only rules matching <paramref name="serial"/> are returned. Lines with
458+
/// unparseable socket specs are skipped.
459+
/// </summary>
460+
/// <remarks>
461+
/// Note the field-order asymmetry vs <see cref="ParseReverseListOutput"/>:
462+
/// forward --list: &lt;serial&gt; &lt;local&gt; &lt;remote&gt;
463+
/// reverse --list: (reverse) &lt;remote&gt; &lt;local&gt;
464+
/// Both parsers construct an <see cref="AdbPortRule"/> whose constructor takes
465+
/// (Remote, Local), so the order in which we pass the parsed parts differs between
466+
/// the two parsers — keep that in mind when modifying either of them.
467+
/// </remarks>
468+
internal static IReadOnlyList<AdbPortRule> ParseForwardListOutput (IEnumerable<string> lines, string serial)
469+
{
470+
var rules = new List<AdbPortRule> ();
471+
if (string.IsNullOrEmpty (serial))
472+
return rules;
473+
474+
foreach (var line in lines) {
475+
var trimmed = line.Trim ();
476+
if (string.IsNullOrEmpty (trimmed))
477+
continue;
478+
479+
// Expected format: "<serial> <local> <remote>" — see <remarks> above for
480+
// the field-order asymmetry with reverse --list.
481+
var parts = trimmed.Split ((char[]?) null, StringSplitOptions.RemoveEmptyEntries);
482+
if (parts.Length < 3)
483+
continue;
484+
485+
if (!string.Equals (parts [0], serial, StringComparison.Ordinal))
486+
continue;
487+
488+
var local = AdbPortSpec.TryParse (parts [1]);
489+
var remote = AdbPortSpec.TryParse (parts [2]);
490+
if (local is { } l && remote is { } r)
491+
rules.Add (new AdbPortRule (r, l));
492+
}
493+
494+
return rules;
495+
}
496+
358497
/// <summary>
359498
/// Parses the output lines from 'adb devices -l'.
360499
/// Accepts an <see cref="IEnumerable{T}"/> to avoid allocating a joined string.

0 commit comments

Comments
 (0)