Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/Changelog-Platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ See full log [of v4.3.3...v4.4.0](https://github.com/microsoft/testfx/compare/v4
* Add an opt-in prototype for deadline-aware cancellation so CI can request a graceful test-framework stop before a hard job deadline, leaving time for reports to finalize and for HangDump to capture a wedged test host, by @nohwnd and @Evangelink in [#10018](https://github.com/microsoft/testfx/pull/10018)
* Suggest uniquely matching command-line options for likely typos and identify the extension package that provides a known but unregistered option, by @Evangelink in [#10798](https://github.com/microsoft/testfx/pull/10798)
* Preserve MSTest `[WorkItem]` and `[GitHubWorkItem]` metadata as schema-compatible work-item definitions in MTP-generated TRX reports, by @Evangelink in [#10861](https://github.com/microsoft/testfx/pull/10861)
* Add `MtpServerClient.LaunchInProcessAsync` to `Microsoft.Testing.Platform.ServerMode.Client.Sources`, so embedded hosts such as MAUI or Android/iOS test apps can drive a Microsoft.Testing.Platform application hosted in their own process without `Process.Start`. The client still owns the loopback listener, the server-mode arguments, the connect race, the transport setup and a bounded shutdown (`MtpServerClientOptions.ServerShutdownTimeout`); the caller only supplies how to build and run the test application. `IMtpServerClient` also gains `ShutdownAsync()` for a non-blocking teardown and `ServerExitCode` for the value the application returned. The path is loopback TCP, so it fails fast with `PlatformNotSupportedException` on browser/WASM in [#10890](https://github.com/microsoft/testfx/issues/10890)

### Changed

Expand Down
19 changes: 19 additions & 0 deletions docs/mstest-runner-protocol/001-protocol-intro.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@ is specified in its own document.
The machine-readable JSON Schema for the base protocol is
[`server-mode-1.0.schema.json`](./server-mode-1.0.schema.json).

## Reference client

Rather than implementing this protocol by hand, clients can consume the canonical, source-only
[`Microsoft.Testing.Platform.ServerMode.Client.Sources`](https://www.nuget.org/packages/Microsoft.Testing.Platform.ServerMode.Client.Sources)
package. It compiles the same protocol and serialization source files the server uses, so it is wire
compatible by construction, and it offers two launch paths:

- `MtpServerClient.LaunchAsync(path)` starts the test application as a **child process**. This is the
default for IDE, CLI and CI tooling.
- `MtpServerClient.LaunchInProcessAsync(callback)` hosts the test application **in the caller's own
process** through a callback, for embedded runners (MAUI, Android/iOS test apps) that cannot spawn a
process. The client generates the complete server-mode argument array
(`--server jsonrpc --client-host <host> --client-port <port> --no-banner`) and hands it to the
callback, which forwards it verbatim to `TestApplication.CreateBuilderAsync`.

Both paths use loopback TCP, so neither works on browser/WASM; the in-process path fails fast with a
`PlatformNotSupportedException` there. See the package's `PACKAGE.md` for ownership, cancellation and
shutdown-bound details.

## API overview

Here's the current list of APIs that supported by the client.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ internal interface IMtpServerClient : IDisposable

/// <summary>
/// Gets the process id of the launched application, or 0 when the client was created over an
/// externally supplied connection (for example in tests).
/// externally supplied connection (for example in tests). For an application hosted in the caller's
/// own process this is the current process id.
/// </summary>
int ProcessId { get; }

Expand All @@ -65,6 +66,23 @@ internal interface IMtpServerClient : IDisposable
/// </summary>
MtpServerCapabilities? Capabilities { get; }

/// <summary>
/// Gets the exit code the launched application reported, or <see langword="null"/> while it is still
/// running, when it failed rather than exiting, or when the client was created over an externally
/// supplied connection.
/// </summary>
/// <remarks>
/// For an externally launched application the value is available only when the process exits on its own;
/// teardown that must forcibly terminate it reports <see langword="null"/> rather than an operating-system
/// kill status.
/// <para>
/// For an application hosted in the caller's own process this is the value the launch callback returned
/// (typically <c>TestApplication.RunAsync</c>'s exit code) and it becomes available once
/// <see cref="ShutdownAsync"/> or <see cref="IDisposable.Dispose"/> has completed.
/// </para>
/// </remarks>
int? ServerExitCode { get; }

/// <summary>
/// Gets or sets an opt-in handler for server-initiated requests (for example the debugger-attach
/// request). The handler receives the request method and parameters and returns the response object
Expand Down Expand Up @@ -141,6 +159,23 @@ internal interface IMtpServerClient : IDisposable
/// Sends the <c>exit</c> notification, asking the application to shut down.
/// </summary>
Task ExitAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Tears the client and the launched application down without blocking the calling thread, then returns.
/// </summary>
/// <remarks>
/// Equivalent to <see cref="IDisposable.Dispose"/> but asynchronous, which matters for an application
/// hosted in the caller's own process: teardown waits for the hosted application, and doing that
/// synchronously on a UI thread can trip a platform responsiveness watchdog (Android ANR, the iOS
/// watchdog). Both entry points share one teardown, so a following <see cref="IDisposable.Dispose"/> is
/// safe and returns as soon as that teardown is done — a <c>using</c> block plus an
/// <c>await client.ShutdownAsync()</c> before it leaves is the recommended pattern on those platforms.
/// For an in-process application, a callback fault or self-cancellation that occurs after connection is
/// rethrown once teardown has finished. Cancellation requested by teardown itself is expected and is not
/// rethrown. Synchronous <see cref="IDisposable.Dispose"/> reports callback failures through the configured
/// logger instead so cleanup cannot mask an exception already propagating from the caller.
/// </remarks>
Task ShutdownAsync();
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Microsoft.Testing.Platform.ServerMode.Client;

/// <summary>
/// A launched Microsoft.Testing.Platform (MTP) server the client owns: either an external process
/// (<see cref="MtpServerProcess"/>) or an application hosted in the caller's own process
/// (<see cref="MtpServerInProcessHost"/>).
/// </summary>
/// <remarks>
/// Disposing the host tears the server down. <see cref="MtpServerClient"/> holds the host and disposes it,
/// so the two launch paths share one ownership rule: whoever launched the server closes the transport and
/// stops the server.
/// </remarks>
internal interface IMtpServerHost : IDisposable
{
/// <summary>
/// Gets the transport connection to the launched application. The read loop is NOT started yet; the
/// owner must attach handlers and call <see cref="MtpJsonRpcConnection.Start"/>.
/// </summary>
MtpJsonRpcConnection Connection { get; }

/// <summary>
/// Gets the process id of the application, or 0 when it is not known (for example a process that has
/// already exited).
/// </summary>
int ProcessId { get; }

/// <summary>
/// Gets the exit code the application reported, or <see langword="null"/> while it is still running (or
/// when it failed rather than exiting).
/// </summary>
int? ExitCode { get; }

/// <summary>
/// Tears the server down without blocking the calling thread.
/// </summary>
/// <remarks>
/// This is the preferred teardown on platforms with a responsiveness watchdog (Android ANR, the iOS
/// watchdog), where the synchronous <see cref="IDisposable.Dispose"/> wait is not acceptable. Both entry
/// points share one teardown, so calling <see cref="IDisposable.Dispose"/> afterwards is safe and returns
/// as soon as that teardown is done — immediately when it has already finished, and otherwise once it
/// does, rather than reporting success while the server is still stopping.
/// </remarks>
Task ShutdownAsync();
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ namespace Microsoft.Testing.Platform.ServerMode.Client;
/// Default <see cref="IMtpServerClient"/> implementation over a <see cref="MtpJsonRpcConnection"/>.
/// </summary>
/// <remarks>
/// Two ways to obtain a client:
/// Three ways to obtain a client:
/// <list type="bullet">
/// <item><see cref="Launch(string, MtpServerClientOptions?)"/> starts the MTP application and owns its process.</item>
/// <item><see cref="Launch(string, MtpServerClientOptions?)"/> starts the MTP application as a child process
/// and owns that process.</item>
/// <item><see cref="LaunchInProcessAsync"/> hosts the MTP application in the caller's own process through a
/// callback and owns the resulting server task (for embedded hosts that cannot spawn a process).</item>
/// <item>The <see cref="MtpServerClient(MtpJsonRpcConnection, MtpServerClientOptions?)"/> constructor wraps an
/// already-connected transport (used by tests over a paired in-memory stream).</item>
/// </list>
Expand All @@ -22,10 +25,11 @@ internal sealed class MtpServerClient : IMtpServerClient
{
private readonly MtpJsonRpcConnection _connection;
private readonly MtpServerClientOptions _options;
private readonly MtpServerProcess? _process;
private readonly IMtpServerHost? _host;
private readonly object _shutdownLock = new();

private Func<string, IDictionary<string, object?>?, CancellationToken, Task<IDictionary<string, object?>?>>? _serverRequestHandler;
private int _disposed;
private Task? _shutdown;

/// <summary>
/// Initializes a new instance of the <see cref="MtpServerClient"/> class over an existing connection.
Expand All @@ -35,8 +39,9 @@ internal sealed class MtpServerClient : IMtpServerClient
/// <remarks>
/// Precondition: the connection's formatter must have been created with the client serializers already
/// registered — call <see cref="SerializerUtilities.RegisterClientSerializers"/> before building the
/// formatter passed to <paramref name="connection"/>. The <see cref="Launch"/> factory does this for you;
/// callers that construct a connection directly are responsible for the ordering.
/// formatter passed to <paramref name="connection"/>. The <see cref="Launch"/> and
/// <see cref="LaunchInProcessAsync"/> factories do this for you; callers that construct a connection
/// directly are responsible for the ordering.
/// </remarks>
public MtpServerClient(MtpJsonRpcConnection connection, MtpServerClientOptions? options = null)
{
Expand All @@ -47,9 +52,9 @@ public MtpServerClient(MtpJsonRpcConnection connection, MtpServerClientOptions?
_connection.ServerRequestHandler = OnServerRequestAsync;
}

private MtpServerClient(MtpServerProcess process, MtpServerClientOptions options)
: this(process.Connection, options)
=> _process = process;
private MtpServerClient(IMtpServerHost host, MtpServerClientOptions options)
: this(host.Connection, options)
=> _host = host;

/// <inheritdoc />
public event EventHandler<MtpTestNodeUpdateEventArgs>? TestNodesUpdated;
Expand All @@ -71,7 +76,10 @@ private MtpServerClient(MtpServerProcess process, MtpServerClientOptions options
}

/// <inheritdoc />
public int ProcessId => _process?.ProcessId ?? 0;
public int ProcessId => _host?.ProcessId ?? 0;

/// <inheritdoc />
public int? ServerExitCode => _host?.ExitCode;

/// <inheritdoc />
public MtpServerCapabilities? Capabilities { get; private set; }
Expand Down Expand Up @@ -114,12 +122,88 @@ public static async Task<MtpServerClient> LaunchAsync(
}
}

/// <summary>
/// Hosts an MTP application in the caller's own process through <paramref name="serverEntryPoint"/> and
/// asynchronously waits for it to connect back.
/// </summary>
/// <param name="serverEntryPoint">
/// Builds and runs the MTP application. It receives the complete server-mode argument array, which it must
/// forward verbatim to the test application, plus a cancellation token, and returns the application's exit
/// code. The callback is invoked on the thread pool, so it never blocks the caller and never inherits the
/// caller's synchronization context.
/// </param>
/// <param name="options">Client options (name, capabilities, connection timeout, shutdown timeout, logger).</param>
/// <param name="cancellationToken">
/// Cancels the launch and the connection wait. It scopes the launch only: once the returned client exists,
/// canceling this token no longer affects the hosted application.
/// </param>
/// <exception cref="PlatformNotSupportedException">
/// The current platform has no loopback TCP listener (browser/WASM). This API is TCP-based and does not
/// enable WASM hosting.
/// </exception>
/// <exception cref="MtpServerConnectionClosedException">
/// The application failed, was canceled, or exited before connecting back, or did not connect back within
/// <see cref="MtpServerClientOptions.ConnectionTimeout"/>. The callback's own exception, when there is one,
/// is the inner exception.
/// </exception>
/// <remarks>
/// <para>
/// This is the embedded-host counterpart of <see cref="LaunchAsync"/>: the client still owns the loopback
/// listener, the server-mode arguments, the connect race, the serializer/formatter/transport setup and the
/// shutdown sequence — the caller only supplies "how to run the application":
/// </para>
/// <code>
/// using IMtpServerClient client = await MtpServerClient.LaunchInProcessAsync(
/// async (serverArgs, token) =>
/// {
/// ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(serverArgs);
/// builder.AddMSTest(() =&gt; testAssemblies);
/// using ITestApplication app = await builder.BuildAsync();
/// return await app.RunAsync();
/// },
/// options,
/// cancellationToken);
/// </code>
/// <para>
/// Ownership: the returned client owns the hosted application. Disposing it closes the transport (which is
/// how a server-mode application is asked to stop) and then waits for the callback within a documented
/// bound — see <see cref="MtpServerClientOptions.ServerShutdownTimeout"/>. Call
/// <see cref="ExitAsync"/> before disposing for a protocol-level shutdown.
/// </para>
/// <para>
/// There is deliberately no synchronous overload: the callback runs in the caller's process, so blocking
/// the launching thread risks deadlocking the very application being launched.
/// </para>
/// </remarks>
public static async Task<MtpServerClient> LaunchInProcessAsync(
Func<string[], CancellationToken, Task<int>> serverEntryPoint,
MtpServerClientOptions? options = null,
CancellationToken cancellationToken = default)
{
if (serverEntryPoint is null)
{
throw new ArgumentNullException(nameof(serverEntryPoint));
}

options ??= new MtpServerClientOptions();
MtpServerInProcessHost host = await MtpServerInProcessHost.StartAsync(serverEntryPoint, options, cancellationToken).ConfigureAwait(false);
try
{
return new MtpServerClient(host, options);
}
catch
{
host.Dispose();
throw;
}
}

/// <inheritdoc />
public async Task<MtpServerCapabilities> InitializeAsync(CancellationToken cancellationToken = default)
{
EnsureStarted();
var args = new InitializeRequestArgs(
GetCurrentProcessId(),
MtpServerConnector.GetCurrentProcessId(),
new ClientInfo(_options.ClientName, _options.ClientVersion),
new ClientCapabilities(_options.DebuggerProvider, _options.IsStateful))
{
Expand Down Expand Up @@ -174,28 +258,41 @@ public Task ExitAsync(CancellationToken cancellationToken = default)
/// <inheritdoc />
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
DetachHandlers();

if (_host is not null)
{
// The host owns and joins its one teardown. Its synchronous disposal also preserves the IDisposable
// contract by reporting a callback failure rather than throwing it.
_host.Dispose();
return;
}

_connection.NotificationReceived -= OnNotificationReceived;
_connection.ServerRequestHandler = null;
// An existing-connection client caches and joins the scheduled fallback teardown.
#pragma warning disable VSTHRD002 // Synchronously waiting on tasks - this IS the synchronous disposal path; ShutdownAsync is the awaitable one.
StartConnectionShutdownAsync().GetAwaiter().GetResult();
#pragma warning restore VSTHRD002
}

if (_process is not null)
{
_process.Dispose();
}
else
/// <inheritdoc />
public Task ShutdownAsync()
{
DetachHandlers();
return _host?.ShutdownAsync() ?? StartConnectionShutdownAsync();
}

private Task StartConnectionShutdownAsync()
{
lock (_shutdownLock)
{
_connection.Dispose();
return _shutdown ??= Task.Run(_connection.Dispose);
}
}

private static int GetCurrentProcessId()
private void DetachHandlers()
{
using var current = Process.GetCurrentProcess();
return current.Id;
_connection.NotificationReceived -= OnNotificationReceived;
_connection.ServerRequestHandler = null;
}

private static ICollection<TestNode> BuildTestNodes(IReadOnlyCollection<string> testNodeUids)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ public MtpServerConnectionClosedException(string message)
: base(message)
{
}

public MtpServerConnectionClosedException(string message, Exception innerException)
: base(message, innerException)
{
}
}

/// <summary>
Expand Down
Loading