diff --git a/docs/Changelog-Platform.md b/docs/Changelog-Platform.md index 1a4ba2447f..9df8da9d9a 100644 --- a/docs/Changelog-Platform.md +++ b/docs/Changelog-Platform.md @@ -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 diff --git a/docs/mstest-runner-protocol/001-protocol-intro.md b/docs/mstest-runner-protocol/001-protocol-intro.md index 6e369bdd16..ac8fcf3133 100644 --- a/docs/mstest-runner-protocol/001-protocol-intro.md +++ b/docs/mstest-runner-protocol/001-protocol-intro.md @@ -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 --client-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. diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/IMtpServerClient.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/IMtpServerClient.cs index 9ff871ea6d..b4af45aeb2 100644 --- a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/IMtpServerClient.cs +++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/IMtpServerClient.cs @@ -55,7 +55,8 @@ internal interface IMtpServerClient : IDisposable /// /// 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. /// int ProcessId { get; } @@ -65,6 +66,23 @@ internal interface IMtpServerClient : IDisposable /// MtpServerCapabilities? Capabilities { get; } + /// + /// Gets the exit code the launched application reported, or while it is still + /// running, when it failed rather than exiting, or when the client was created over an externally + /// supplied connection. + /// + /// + /// For an externally launched application the value is available only when the process exits on its own; + /// teardown that must forcibly terminate it reports rather than an operating-system + /// kill status. + /// + /// For an application hosted in the caller's own process this is the value the launch callback returned + /// (typically TestApplication.RunAsync's exit code) and it becomes available once + /// or has completed. + /// + /// + int? ServerExitCode { get; } + /// /// 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 @@ -141,6 +159,23 @@ internal interface IMtpServerClient : IDisposable /// Sends the exit notification, asking the application to shut down. /// Task ExitAsync(CancellationToken cancellationToken = default); + + /// + /// Tears the client and the launched application down without blocking the calling thread, then returns. + /// + /// + /// Equivalent to 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 is + /// safe and returns as soon as that teardown is done — a using block plus an + /// await client.ShutdownAsync() 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 reports callback failures through the configured + /// logger instead so cleanup cannot mask an exception already propagating from the caller. + /// + Task ShutdownAsync(); } /// diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/IMtpServerHost.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/IMtpServerHost.cs new file mode 100644 index 0000000000..23e9680190 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/IMtpServerHost.cs @@ -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; + +/// +/// A launched Microsoft.Testing.Platform (MTP) server the client owns: either an external process +/// () or an application hosted in the caller's own process +/// (). +/// +/// +/// Disposing the host tears the server down. 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. +/// +internal interface IMtpServerHost : IDisposable +{ + /// + /// Gets the transport connection to the launched application. The read loop is NOT started yet; the + /// owner must attach handlers and call . + /// + MtpJsonRpcConnection Connection { get; } + + /// + /// Gets the process id of the application, or 0 when it is not known (for example a process that has + /// already exited). + /// + int ProcessId { get; } + + /// + /// Gets the exit code the application reported, or while it is still running (or + /// when it failed rather than exiting). + /// + int? ExitCode { get; } + + /// + /// Tears the server down without blocking the calling thread. + /// + /// + /// This is the preferred teardown on platforms with a responsiveness watchdog (Android ANR, the iOS + /// watchdog), where the synchronous wait is not acceptable. Both entry + /// points share one teardown, so calling 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. + /// + Task ShutdownAsync(); +} diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClient.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClient.cs index 0fe0eb3ffa..f08c72a8d0 100644 --- a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClient.cs +++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClient.cs @@ -9,9 +9,12 @@ namespace Microsoft.Testing.Platform.ServerMode.Client; /// Default implementation over a . /// /// -/// Two ways to obtain a client: +/// Three ways to obtain a client: /// -/// starts the MTP application and owns its process. +/// starts the MTP application as a child process +/// and owns that process. +/// 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). /// The constructor wraps an /// already-connected transport (used by tests over a paired in-memory stream). /// @@ -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?, CancellationToken, Task?>>? _serverRequestHandler; - private int _disposed; + private Task? _shutdown; /// /// Initializes a new instance of the class over an existing connection. @@ -35,8 +39,9 @@ internal sealed class MtpServerClient : IMtpServerClient /// /// Precondition: the connection's formatter must have been created with the client serializers already /// registered — call before building the - /// formatter passed to . The factory does this for you; - /// callers that construct a connection directly are responsible for the ordering. + /// formatter passed to . The and + /// factories do this for you; callers that construct a connection + /// directly are responsible for the ordering. /// public MtpServerClient(MtpJsonRpcConnection connection, MtpServerClientOptions? options = null) { @@ -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; /// public event EventHandler? TestNodesUpdated; @@ -71,7 +76,10 @@ private MtpServerClient(MtpServerProcess process, MtpServerClientOptions options } /// - public int ProcessId => _process?.ProcessId ?? 0; + public int ProcessId => _host?.ProcessId ?? 0; + + /// + public int? ServerExitCode => _host?.ExitCode; /// public MtpServerCapabilities? Capabilities { get; private set; } @@ -114,12 +122,88 @@ public static async Task LaunchAsync( } } + /// + /// Hosts an MTP application in the caller's own process through and + /// asynchronously waits for it to connect back. + /// + /// + /// 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. + /// + /// Client options (name, capabilities, connection timeout, shutdown timeout, logger). + /// + /// 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. + /// + /// + /// The current platform has no loopback TCP listener (browser/WASM). This API is TCP-based and does not + /// enable WASM hosting. + /// + /// + /// The application failed, was canceled, or exited before connecting back, or did not connect back within + /// . The callback's own exception, when there is one, + /// is the inner exception. + /// + /// + /// + /// This is the embedded-host counterpart of : 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": + /// + /// + /// using IMtpServerClient client = await MtpServerClient.LaunchInProcessAsync( + /// async (serverArgs, token) => + /// { + /// ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(serverArgs); + /// builder.AddMSTest(() => testAssemblies); + /// using ITestApplication app = await builder.BuildAsync(); + /// return await app.RunAsync(); + /// }, + /// options, + /// cancellationToken); + /// + /// + /// 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 . Call + /// before disposing for a protocol-level shutdown. + /// + /// + /// 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. + /// + /// + public static async Task LaunchInProcessAsync( + Func> 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; + } + } + /// public async Task InitializeAsync(CancellationToken cancellationToken = default) { EnsureStarted(); var args = new InitializeRequestArgs( - GetCurrentProcessId(), + MtpServerConnector.GetCurrentProcessId(), new ClientInfo(_options.ClientName, _options.ClientVersion), new ClientCapabilities(_options.DebuggerProvider, _options.IsStateful)) { @@ -174,28 +258,41 @@ public Task ExitAsync(CancellationToken cancellationToken = default) /// 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 + /// + 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 BuildTestNodes(IReadOnlyCollection testNodeUids) diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClientExceptions.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClientExceptions.cs index 60003de3fa..09c8134c89 100644 --- a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClientExceptions.cs +++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClientExceptions.cs @@ -38,6 +38,11 @@ public MtpServerConnectionClosedException(string message) : base(message) { } + + public MtpServerConnectionClosedException(string message, Exception innerException) + : base(message, innerException) + { + } } /// diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClientOptions.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClientOptions.cs index 835633ac6c..8408231808 100644 --- a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClientOptions.cs +++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClientOptions.cs @@ -56,10 +56,29 @@ internal sealed class MtpServerClientOptions /// public TimeSpan ConnectionTimeout { get; set; } = TimeSpan.FromSeconds(90); + /// + /// Gets or sets how long disposal waits for an in-process hosted application (see + /// MtpServerClient.LaunchInProcessAsync) to stop after the transport has been closed, before the + /// callback's cancellation token is canceled. Defaults to 30 seconds. + /// + /// + /// Disposal waits at most after the transport + /// is closed, plus a further fixed 5 seconds after the callback's token is canceled. This option has no + /// effect on the external-process launch path, which kills the child process instead, and it does not + /// apply to a failed launch: nothing is connected there, so the callback is canceled immediately + /// and only the fixed 5-second grace applies. + /// + public TimeSpan ServerShutdownTimeout { get; set; } = TimeSpan.FromSeconds(30); + /// /// Gets the environment variables injected into the launched test-app process (in addition to the /// inherited environment). Useful for passing configuration such as diagnostics switches. /// + /// + /// Only applies to the external-process launch path. An application hosted in the caller's own process + /// shares the caller's environment, so MtpServerClient.LaunchInProcessAsync ignores this + /// collection (and logs a warning when it is non-empty). + /// public IDictionary EnvironmentVariables { get; } = new Dictionary(StringComparer.Ordinal); /// diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerConnector.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerConnector.cs new file mode 100644 index 0000000000..424ebc346e --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerConnector.cs @@ -0,0 +1,321 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Net; +using System.Net.Sockets; + +namespace Microsoft.Testing.Platform.ServerMode.Client; + +/// +/// The transport setup shared by every way of launching a Microsoft.Testing.Platform (MTP) application in +/// JSON-RPC server mode: registering the client serializers before the formatter is built, binding the +/// loopback listener the application dials back to, racing the accept against server failure / caller +/// cancellation / the connection timeout, and wrapping the accepted socket in the platform's own +/// plus a . +/// +/// +/// (external process) and (callback in +/// the caller's own process) differ only in how the server is started and how "the server already stopped" +/// is observed. Everything else lives here so the two launch paths cannot drift apart. +/// +internal static class MtpServerConnector +{ + internal const string ServerArgument = "--server"; + internal const string JsonRpcProtocolArgument = "jsonrpc"; + internal const string ClientHostArgument = "--client-host"; + internal const string ClientPortArgument = "--client-port"; + internal const string NoBannerArgument = "--no-banner"; + + /// + /// The loopback address handed to the server. The listener binds , so the + /// dotted form is passed explicitly rather than relying on the server's localhost default, which + /// resolves through DNS. + /// + internal const string LoopbackHost = "127.0.0.1"; + + /// + /// How often the connect wait re-checks whether the launched server has already stopped, so a server that + /// dies during startup fails fast instead of blocking for the full connection timeout. + /// + internal static readonly TimeSpan ServerStoppedPollInterval = TimeSpan.FromMilliseconds(100); + + /// + /// The largest delay accepts on every target framework (.NET Framework + /// rejects anything above milliseconds). Bounded waits clamp to it so an + /// absurd caller-supplied timeout cannot turn a teardown into an exception. + /// + private static readonly TimeSpan MaxDelay = TimeSpan.FromMilliseconds(int.MaxValue); + + /// + /// Registers the client serializers and creates the message formatter, in that order. + /// + /// + /// The ordering is load-bearing: the .NET System.Text.Json formatter snapshots the registered + /// serializer/deserializer type sets into its per-type engine at construction time, so a formatter built + /// before registration would silently miss the client payload types. + /// + public static IMessageFormatter CreateFormatter() + { + SerializerUtilities.RegisterClientSerializers(); + return FormatterUtilities.CreateFormatter(); + } + + /// + /// Binds and starts a loopback TCP listener on an OS-assigned ephemeral port. + /// + /// Receives the bound port to hand to the server. + public static TcpListener StartLoopbackListener(out int port) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + try + { + // Start() is inside the try: it creates the underlying socket before binding, so a bind or listen + // failure (port exhaustion, a sandbox that forbids listening) would otherwise leak that socket + // because the caller never receives a listener it could stop. + listener.Start(); + port = ((IPEndPoint)listener.LocalEndpoint).Port; + } + catch + { + try + { + listener.Stop(); + } + catch (Exception) + { + // Best-effort cleanup: the original failure is the one worth reporting. + } + + throw; + } + + return listener; + } + + /// + /// Builds the complete server-mode argument array for an application hosted in the caller's own process. + /// + /// The port of the client's loopback listener. + /// + /// The protocol and host are stated explicitly (rather than relying on the platform's defaults) because + /// an embedded host forwards this array verbatim to TestApplication.CreateBuilderAsync, where it is + /// also the documentation of what the client asked for. + /// + public static string[] BuildInProcessServerArguments(int port) + => + [ + ServerArgument, + JsonRpcProtocolArgument, + ClientHostArgument, + LoopbackHost, + ClientPortArgument, + port.ToString(CultureInfo.InvariantCulture), + NoBannerArgument, + ]; + + /// + /// Waits for the launched server to dial back, racing the accept against server failure, caller + /// cancellation and so the wait is never unbounded. + /// + /// The already-started loopback listener. + /// + /// Probed on every poll. Returns the exception to throw when the server has already stopped without + /// connecting back, or while it is still running. + /// + /// Creates the exception thrown when the connection timeout elapses. + /// Upper bound on the wait for the server to connect back. + /// + /// Optional completion signal for the launched server. When supplied it joins the wait so a server that + /// stops is observed immediately instead of on the next poll tick. + /// + /// Cancels the wait. + /// + /// On any failure the still-pending accept is neutralized before the exception propagates, so a late + /// dial-back cannot leak a connected socket and the accept task can never end up unobserved. + /// + public static async Task AcceptAsync( + TcpListener listener, + Func tryGetServerStoppedFailure, + Func createTimeoutFailure, + TimeSpan connectionTimeout, + Task? serverCompletion, + CancellationToken cancellationToken) + { + Task? acceptTask = null; + TcpClient? acceptedClient = null; + try + { +#if NET8_0_OR_GREATER + acceptTask = listener.AcceptTcpClientAsync(cancellationToken).AsTask(); +#else + acceptTask = listener.AcceptTcpClientAsync(); +#endif + + var connectStopwatch = Stopwatch.StartNew(); + while (!acceptTask.IsCompleted) + { + cancellationToken.ThrowIfCancellationRequested(); + + // No grace period here on purpose. Once the server has stopped, any socket the accept might + // still yield belongs to a dead peer, so waiting for it would only trade this precise failure + // (exit code, captured stderr) for a generic connection-closed error on the first request. + // The loop re-checks acceptTask above, so a connection that completed while the server was + // still alive is already taken before this probe runs. + if (tryGetServerStoppedFailure() is { } serverStopped) + { + throw serverStopped; + } + + if (connectStopwatch.Elapsed >= connectionTimeout) + { + throw createTimeoutFailure(); + } + + var delayTask = Task.Delay(ServerStoppedPollInterval, cancellationToken); + _ = serverCompletion is null + ? await Task.WhenAny(acceptTask, delayTask).ConfigureAwait(false) + : await Task.WhenAny(acceptTask, delayTask, serverCompletion).ConfigureAwait(false); + } + + cancellationToken.ThrowIfCancellationRequested(); + acceptedClient = await acceptTask.ConfigureAwait(false); + acceptedClient.NoDelay = true; + return acceptedClient; + } + catch + { + if (acceptedClient is null && acceptTask is not null) + { + NeutralizePendingAccept(acceptTask); + } + + acceptedClient?.Dispose(); + throw; + } + } + + /// + /// Wraps an accepted socket in the platform's own framing handler and the client's JSON-RPC connection. + /// + /// + /// The read loop is intentionally NOT started: the owner attaches its notification and server-request + /// handlers first and only then calls , so no server-to-client + /// message can slip past before the handlers are attached. + /// + public static MtpJsonRpcConnection CreateConnection(TcpClient client, IMessageFormatter formatter, IMtpClientLogger logger) + { + // A NetworkStream is duplex, so the same stream serves the read and write directions. + NetworkStream stream = client.GetStream(); + var handler = new TcpMessageHandler(client, stream, stream, formatter); + return new MtpJsonRpcConnection(handler, logger); + } + + /// + /// Stops the listener, logging rather than propagating a socket failure so teardown never masks the + /// failure that triggered it. + /// + public static void SafeStop(TcpListener listener, IMtpClientLogger logger) + { + try + { + listener.Stop(); + } + catch (SocketException ex) + { + logger.SafeLog(MtpClientLogLevel.Debug, $"Stopping the TCP listener threw: {ex}"); + } + } + + /// + /// Gets the id of the process the client itself runs in. + /// + public static int GetCurrentProcessId() + { + using var current = Process.GetCurrentProcess(); + return current.Id; + } + + /// + /// Waits for for at most and reports whether it + /// completed. Never throws, and never propagates the task's own failure: the caller decides how to report + /// it, which keeps a shutdown failure from masking the primary one. + /// + /// + /// A caller-supplied timeout is clamped rather than trusted: this runs on disposal and teardown paths + /// that are documented never to throw, so a non-positive value degrades to "check, do not wait" (which + /// also covers , since an unbounded wait is exactly what these paths + /// must not do) and an oversized one is capped to the largest delay every target framework accepts. + /// + public static async Task WaitBoundedAsync(Task task, TimeSpan timeout) + { + if (task.IsCompleted) + { + return true; + } + + if (timeout <= TimeSpan.Zero) + { + return false; + } + + if (timeout > MaxDelay) + { + timeout = MaxDelay; + } + + Task completed = await Task.WhenAny(task, Task.Delay(timeout, CancellationToken.None)).ConfigureAwait(false); + return completed == task; + } + + /// + /// Reads a completed task's failure so it is observed (never surfacing as an + /// UnobservedTaskException) and reports it through the logger instead of throwing. + /// + public static void ObserveFailure(Task task, IMtpClientLogger logger, string description) + { + if (!task.IsCompleted) + { + // Still running (abandoned): attach a continuation so a later failure is both observed and + // reported, rather than disappearing because nothing was left to await it. + _ = task.ContinueWith( + (completed, state) => + { + if (completed.Exception is { } late) + { + ((IMtpClientLogger)state!).SafeLog(MtpClientLogLevel.Error, $"{description}: {late}"); + } + }, + logger, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + return; + } + + if (task.Exception is { } exception) + { + logger.SafeLog(MtpClientLogLevel.Error, $"{description}: {exception}"); + } + } + + /// + /// Neutralizes an accept that is still pending on a failed launch: any socket it eventually yields is + /// disposed, and any fault it eventually reports is observed. + /// + private static void NeutralizePendingAccept(Task acceptTask) + => _ = acceptTask.ContinueWith( + static t => + { + if (t.Status == TaskStatus.RanToCompletion) + { + t.Result.Dispose(); + } + else + { + _ = t.Exception; + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); +} diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerInProcessHost.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerInProcessHost.cs new file mode 100644 index 0000000000..e352782f1c --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerInProcessHost.cs @@ -0,0 +1,460 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Net.Sockets; + +namespace Microsoft.Testing.Platform.ServerMode.Client; + +/// +/// Hosts a Microsoft.Testing.Platform (MTP) application in the caller's own process and owns the resulting +/// . +/// +/// +/// This is the embedded counterpart of , for hosts that cannot spawn a child +/// process (MAUI, Android/iOS test apps, single-process tooling). The client still opens the loopback TCP +/// listener and still speaks the real server-mode wire protocol; only the "start the application" step is +/// delegated to a caller-supplied callback, which receives the complete server-mode argument array and is +/// expected to build and run an MTP TestApplication with it. +/// +internal sealed class MtpServerInProcessHost : IMtpServerHost +{ + /// + /// Extra bounded wait granted after the server callback is asked to cancel, on top of + /// . + /// + private static readonly TimeSpan CancellationGrace = TimeSpan.FromSeconds(5); + + private readonly TcpListener _listener; + private readonly TcpClient _client; + private readonly Task _serverTask; + private readonly CancellationTokenSource _serverCancellation; + private readonly TimeSpan _shutdownTimeout; + private readonly IMtpClientLogger _logger; + private readonly object _shutdownLock = new(); + + private Task? _shutdown; + + /// + /// The exit code captured during teardown, boxed so reads and writes are atomic on every target platform. + /// + private object? _capturedExitCode; + + private MtpServerInProcessHost( + TcpListener listener, + TcpClient client, + MtpJsonRpcConnection connection, + Task serverTask, + CancellationTokenSource serverCancellation, + TimeSpan shutdownTimeout, + IMtpClientLogger logger) + { + _listener = listener; + _client = client; + Connection = connection; + _serverTask = serverTask; + _serverCancellation = serverCancellation; + _shutdownTimeout = shutdownTimeout; + _logger = logger; + + // Snapshot once: the id cannot change for the lifetime of the host, and resolving it allocates and + // disposes a Process instance, which a property read must not do on every access. + ProcessId = MtpServerConnector.GetCurrentProcessId(); + } + + /// + public MtpJsonRpcConnection Connection { get; } + + /// + /// Gets the id of the process the application runs in. The application is hosted in the caller's own + /// process, so this is always the current process id. + /// + public int ProcessId { get; } + + /// + /// Gets the exit code the hosted application returned, or while it is still + /// running (or when it failed or was abandoned rather than returning one). + /// + public int? ExitCode + => Volatile.Read(ref _capturedExitCode) is int exitCode ? exitCode : null; + + /// + /// Starts the application through and waits for it to connect back. + /// + /// + /// 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 token that is canceled when the client gives up + /// waiting for the application to stop, and returns the application's exit code. + /// + /// Client options (connection timeout, shutdown timeout, logger). + /// Cancels the launch and the connection wait. + /// The current platform has no loopback TCP support. + /// + /// The application failed, was canceled, or exited before connecting back, or did not connect back within + /// . The callback's own exception, when there is one, + /// is the inner exception. + /// + /// + /// When the launch fails the callback is abandoned promptly: its token is canceled straight away (there is + /// no connected transport whose closure could signal it) and it is then given the same fixed 5-second + /// grace disposal uses, so an unwinding caller never waits for + /// . + /// + public static async Task StartAsync( + Func> serverEntryPoint, + MtpServerClientOptions options, + CancellationToken cancellationToken) + { + if (serverEntryPoint is null) + { + throw new ArgumentNullException(nameof(serverEntryPoint)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + cancellationToken.ThrowIfCancellationRequested(); + IMtpClientLogger logger = options.Logger ?? NullMtpClientLogger.Instance; + + // The server-mode transport is loopback TCP. The browser sandbox has no listening sockets, so fail + // with a precise diagnostic instead of an opaque socket error: this API does not enable WASM hosting. + if (MtpClientOperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException( + "Hosting an MTP application in process requires a loopback TCP listener, " + + "which is not available on browser/WASM."); + } + + if (options.EnvironmentVariables.Count > 0) + { + logger.SafeLog( + MtpClientLogLevel.Warning, + $"{nameof(MtpServerClientOptions)}.{nameof(MtpServerClientOptions.EnvironmentVariables)} is ignored when the " + + "application is hosted in process: it shares the caller's environment. Set the variables before starting the host."); + } + + IMessageFormatter formatter = MtpServerConnector.CreateFormatter(); + + TcpListener? listener = null; + CancellationTokenSource? serverCancellation = null; + Task? serverTask = null; + TcpClient? acceptedClient = null; + try + { + listener = MtpServerConnector.StartLoopbackListener(out int port); + string[] serverArguments = MtpServerConnector.BuildInProcessServerArguments(port); + logger.SafeLog( + MtpClientLogLevel.Debug, + $"Hosting an MTP server in process with '{string.Join(" ", serverArguments)}'."); + + // The token is deliberately NOT linked to cancellationToken: that token scopes the launch, while + // this one scopes the hosted application's lifetime. Linking them would tear the application down + // when a caller reuses a request-scoped token for the launch. + serverCancellation = new CancellationTokenSource(); + serverTask = StartServerAsync(serverEntryPoint, serverArguments, serverCancellation.Token); + + acceptedClient = await MtpServerConnector.AcceptAsync( + listener, + () => TryGetServerStoppedFailure(serverTask), + () => new MtpServerConnectionClosedException( + $"The in-process MTP application did not connect back within {options.ConnectionTimeout.TotalSeconds:N0}s. " + + "Make sure the callback forwards the supplied server-mode arguments to the test application."), + options.ConnectionTimeout, + serverTask, + cancellationToken).ConfigureAwait(false); + + MtpJsonRpcConnection connection = MtpServerConnector.CreateConnection(acceptedClient, formatter, logger); + return new MtpServerInProcessHost( + listener, + acceptedClient, + connection, + serverTask, + serverCancellation, + options.ServerShutdownTimeout, + logger); + } + catch + { + // Tear down whatever was already created. Every step is individually guarded and reported through + // the logger so a teardown failure can never replace the primary launch failure rethrown below. + SafeDispose(acceptedClient, logger, "Disposing the accepted client socket"); + + if (listener is not null) + { + MtpServerConnector.SafeStop(listener, logger); + } + + if (serverTask is not null + && !await ShutdownServerAsync(serverTask, serverCancellation!, TimeSpan.Zero, logger).ConfigureAwait(false)) + { + // The launch is being abandoned, so skip the graceful wait entirely: there is no connected + // transport whose closure could signal the callback, and the caller (often a canceling one) is + // waiting on this unwind. A zero graceful timeout goes straight to cancel-then-grace. + // The callback is still running and still holds the token; disposing its source now would + // turn a clean abandonment into an ObjectDisposedException inside the caller's own code. + throw; + } + + SafeDispose(serverCancellation, logger, "Disposing the server cancellation source"); + throw; + } + } + + /// + /// Closes the transport and waits for the hosted application to finish. + /// + /// + /// The wait is bounded: at most after the + /// transport is closed, plus a further 5 seconds after the callback's token is canceled. If the callback + /// is still running after that it is abandoned (its failure is still observed and logged) rather than + /// hanging the caller. Disposal never throws, so it cannot mask a failure that is already propagating. + /// + /// The wait happens synchronously on the calling thread. Prefer on platforms + /// with a responsiveness watchdog; disposing afterwards then returns immediately. + /// + /// + public void Dispose() + { + // Joins the one teardown rather than starting a second, so a Dispose that races or follows + // ShutdownAsync still returns only once the application has actually stopped. +#pragma warning disable VSTHRD002 // Synchronously waiting on tasks - this IS the synchronous disposal path; ShutdownAsync is the awaitable one. + try + { + StartShutdownAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + // ShutdownAsync surfaces the callback's own post-connect failure. Dispose reports it instead so it + // cannot replace an exception already unwinding through a using statement or finally block. + _logger.SafeLog(MtpClientLogLevel.Error, $"The in-process MTP application failed during disposal: {ex}"); + } +#pragma warning restore VSTHRD002 + } + + /// + public Task ShutdownAsync() + => StartShutdownAsync(); + + /// + /// Returns the single teardown task, starting it on the first call. + /// + /// + /// Teardown runs on the thread pool so the synchronous wait cannot deadlock against + /// a continuation that wants the caller's synchronization context (a UI thread, typically). Because every + /// caller receives the same task, and are idempotent + /// with respect to each other and to themselves. + /// + /// captures the ambient execution context, so the connection's + /// read-loop marker still flows into the teardown. Disposing from inside a + /// notification handler therefore continues to skip the connection's read-loop self-wait instead of + /// stalling for its shutdown timeout. + /// + /// + private Task StartShutdownAsync() + { + lock (_shutdownLock) + { + return _shutdown ??= Task.Run(ShutdownCoreAsync); + } + } + + private async Task ShutdownCoreAsync() + { + CancellationToken serverCancellationToken = _serverCancellation.Token; + try + { + // Start the application timeout before disposing the connection. Connection.Dispose closes the + // transport promptly but can then spend up to five seconds waiting for a blocked read-loop handler; + // overlapping that wait keeps the complete teardown within shutdown timeout plus cancellation grace. + Task serverShutdown = ShutdownServerAsync(_serverTask, _serverCancellation, _shutdownTimeout, _logger); + + // Close the transport first. A server-mode application exits its message loop when the client's + // connection reaches EOF, so this is the graceful stop signal even for a callback that ignores the + // cancellation token (the common case: TestApplication.RunAsync takes none). + Connection.Dispose(); + SafeDispose(_client, _logger, "Disposing the accepted client socket"); + MtpServerConnector.SafeStop(_listener, _logger); + + bool stopped = await serverShutdown.ConfigureAwait(false); + + if (_serverTask.Status == TaskStatus.RanToCompletion) + { + // Reading Result cannot block here: the status check already established the task completed + // successfully. Awaiting instead would be wrong, because a faulted or abandoned task must not + // throw out of a teardown that is documented never to throw. +#pragma warning disable VSTHRD103 // Result synchronously blocks + Volatile.Write(ref _capturedExitCode, _serverTask.Result); +#pragma warning restore VSTHRD103 + } + + if (stopped) + { + // Only safe once nothing holds the token any more: an abandoned callback (or a cancellation + // registration still executing) would see token.WaitHandle / CreateLinkedTokenSource throw + // inside the caller's own code. + SafeDispose(_serverCancellation, _logger, "Disposing the server cancellation source"); + } + } + catch (Exception ex) + { + // Teardown infrastructure failures are reported rather than replacing the callback's own failure. + _logger.SafeLog(MtpClientLogLevel.Error, $"Tearing down the in-process MTP application threw: {ex}"); + } + + // ShutdownAsync is the explicit failure-observing path. Rethrow a callback fault or independent + // cancellation after every owned resource has been handled; cancellation requested by this teardown + // is expected, and an abandoned callback is observed by ShutdownServerAsync's continuation instead. + if (_serverTask.IsFaulted) + { + _ = await _serverTask.ConfigureAwait(false); + } + else if (_serverTask.IsCanceled) + { + try + { + _ = await _serverTask.ConfigureAwait(false); + } + catch (OperationCanceledException ex) + when (ex.CancellationToken == serverCancellationToken || serverCancellationToken.IsCancellationRequested) + { + // The callback honored cancellation after teardown requested it, either directly through the + // supplied token or through a linked token that carries a different identity. + } + } + } + + /// + /// Invokes the callback on the thread pool so the launch never blocks the caller, and turns a synchronous + /// throw (or a null task) into a faulted task the connect race can report. + /// + private static Task StartServerAsync( + Func> serverEntryPoint, + string[] serverArguments, + CancellationToken serverCancellationToken) + => Task.Run( + () => serverEntryPoint(serverArguments, serverCancellationToken) + ?? throw new MtpServerClientException("The in-process server callback returned a null task."), + CancellationToken.None); + + /// + /// Reports why the hosted application can no longer connect back, or while it is + /// still running. + /// + private static Exception? TryGetServerStoppedFailure(Task serverTask) + { + if (!serverTask.IsCompleted) + { + return null; + } + + if (serverTask.IsCanceled) + { + return new MtpServerConnectionClosedException( + "The in-process MTP application was canceled before connecting back.", + new TaskCanceledException(serverTask)); + } + + // Unwrap the AggregateException so the callback's own exception is the inner exception a caller sees. + if (serverTask.Exception is { } exception) + { + Exception failure = exception.InnerExceptions.Count == 1 ? exception.InnerExceptions[0] : exception; + return new MtpServerConnectionClosedException( + "The in-process MTP application failed before connecting back.", + failure); + } + + return new MtpServerConnectionClosedException( + $"The in-process MTP application exited with code {serverTask.Result} before connecting back. " + + "Make sure the callback forwards the supplied server-mode arguments to the test application."); + } + + /// + /// Waits for the hosted application to finish, escalating to cancellation and then to abandonment. Never + /// throws: the application's own failure is observed and logged so it cannot mask the caller's failure. + /// + /// The running callback. + /// The cancellation source handed to the callback. + /// + /// How long the callback is given to stop on its own before its token is canceled. + /// skips the graceful wait, which is what an abandoned launch wants: nothing + /// has been connected, so there is no transport closure for the callback to observe. + /// + /// Sink for the shutdown diagnostics. + /// + /// when the callback finished and no cancellation registration is still running, + /// otherwise. The caller uses this to decide whether it may dispose the + /// cancellation source: anything still holding the token would see token.WaitHandle or + /// CancellationTokenSource.CreateLinkedTokenSource(token) throw + /// inside the caller's own code. + /// + private static async Task ShutdownServerAsync( + Task serverTask, + CancellationTokenSource serverCancellation, + TimeSpan gracefulTimeout, + IMtpClientLogger logger) + { + bool stopped = true; + if (!await MtpServerConnector.WaitBoundedAsync(serverTask, gracefulTimeout).ConfigureAwait(false)) + { + if (gracefulTimeout > TimeSpan.Zero) + { + logger.SafeLog( + MtpClientLogLevel.Debug, + $"The in-process MTP application did not stop within {gracefulTimeout.TotalSeconds:N0}s of the transport closing; requesting cancellation."); + } + + // Cancel() runs registrations synchronously on the calling thread, so a caller registration that + // blocks would stop the grace below from ever starting and make this "bounded" wait unbounded. + // Kick it off separately and start the grace regardless. + var cancelling = Task.Run(() => SafeCancel(serverCancellation, logger)); + MtpServerConnector.ObserveFailure(cancelling, logger, "Canceling the in-process MTP application failed"); + + if (!await MtpServerConnector.WaitBoundedAsync(serverTask, CancellationGrace).ConfigureAwait(false)) + { + stopped = false; + logger.SafeLog( + MtpClientLogLevel.Warning, + $"The in-process MTP application is still running {CancellationGrace.TotalSeconds:N0}s after cancellation was requested; abandoning it."); + } + else if (!cancelling.IsCompleted) + { + // A cancellation registration is still executing and still holds the token. Report the source + // as unsafe to dispose: leaking one CancellationTokenSource beats a use-after-dispose. + stopped = false; + } + } + + MtpServerConnector.ObserveFailure(serverTask, logger, "The in-process MTP application failed"); + return stopped; + } + + private static void SafeCancel(CancellationTokenSource cancellationTokenSource, IMtpClientLogger logger) + { + try + { + cancellationTokenSource.Cancel(); + } + catch (Exception ex) when (ex is ObjectDisposedException or AggregateException) + { + logger.SafeLog(MtpClientLogLevel.Debug, $"Canceling the in-process MTP application threw: {ex}"); + } + } + + private static void SafeDispose(IDisposable? disposable, IMtpClientLogger logger, string description) + { + if (disposable is null) + { + return; + } + + try + { + disposable.Dispose(); + } + catch (Exception ex) when (ex is SocketException or ObjectDisposedException or IOException) + { + logger.SafeLog(MtpClientLogLevel.Debug, $"{description} threw: {ex}"); + } + } +} diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerProcess.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerProcess.cs index bee7d444d6..fdcf4bb536 100644 --- a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerProcess.cs +++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerProcess.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.ComponentModel; -using System.Net; using System.Net.Sockets; namespace Microsoft.Testing.Platform.ServerMode.Client; @@ -19,11 +18,11 @@ namespace Microsoft.Testing.Platform.ServerMode.Client; /// formatter (Jsonite on .NET Framework / netstandard, in-box System.Text.Json on .NET), so the wire /// is byte-for-byte identical to the server's expectations. /// -internal sealed class MtpServerProcess : IDisposable +internal sealed class MtpServerProcess : IMtpServerHost { - private const string ServerArgument = "--server"; - private const string ClientPortArgument = "--client-port"; - private const string NoBannerArgument = "--no-banner"; + private const string ServerArgument = MtpServerConnector.ServerArgument; + private const string ClientPortArgument = MtpServerConnector.ClientPortArgument; + private const string NoBannerArgument = MtpServerConnector.NoBannerArgument; // Bounded wait after killing the process so the OS releases the executable's file locks before a // caller (for example an acceptance test) deletes the application directory. @@ -36,17 +35,23 @@ internal sealed class MtpServerProcess : IDisposable // cap is exceeded the oldest text is dropped from the front and the most recent output is kept. private const int MaxStandardErrorLength = 64 * 1024; - // How often the connect wait re-checks whether the launched process has already exited, so a child - // that dies on startup fails fast instead of blocking the full ConnectionTimeout. - private static readonly TimeSpan ProcessExitPollInterval = TimeSpan.FromMilliseconds(100); + private static readonly object NoExitCode = new(); private readonly TcpListener _listener; private readonly Process _process; private readonly IMtpClientLogger _logger; private readonly StringBuilder _standardError; private readonly TcpClient _client; + private readonly object _shutdownLock = new(); - private int _disposed; + private Task? _shutdown; + + /// + /// The exit code captured during teardown, boxed so the read is atomic. means + /// teardown has not captured a result yet; means teardown completed without an + /// application-returned code. + /// + private object? _capturedExitCode; private MtpServerProcess(TcpListener listener, Process process, TcpClient client, MtpJsonRpcConnection connection, StringBuilder standardError, IMtpClientLogger logger) { @@ -82,6 +87,48 @@ public int ProcessId } } + /// + /// Gets the exit code of the launched application, or while it is still running or + /// when forced termination was required. + /// + /// + /// Once teardown has run this returns the value captured then: a cannot be read + /// after it is disposed, so a live read would always report afterwards. + /// + public int? ExitCode + { + get + { + object? captured = Volatile.Read(ref _capturedExitCode); + return captured switch + { + null => TryReadExitCode(), + int exitCode => exitCode, + _ => null, + }; + } + } + + private int? TryReadExitCode() + { + try + { + return _process.HasExited ? _process.ExitCode : null; + } + catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException) + { + return null; + } + } + + /// + /// Tears the process down without blocking the caller: killing a child process is bounded but still + /// synchronous (it waits for the OS to release the executable's file locks), so it is moved off the + /// calling thread. Shares the one teardown with . + /// + public Task ShutdownAsync() + => StartShutdownAsync(); + /// /// Launches the MTP application at and waits for it to connect back. /// @@ -123,13 +170,11 @@ public static async Task StartAsync( // The serializers must be registered BEFORE the formatter is created: the .NET // System.Text.Json formatter snapshots the registered serializer/deserializer type sets into // its per-type engine at construction time. - SerializerUtilities.RegisterClientSerializers(); - IMessageFormatter formatter = FormatterUtilities.CreateFormatter(); + IMessageFormatter formatter = MtpServerConnector.CreateFormatter(); TcpListener? listener = null; Process? process = null; TcpClient? acceptedClient = null; - Task? acceptTask = null; var standardError = new StringBuilder(); try { @@ -137,9 +182,7 @@ public static async Task StartAsync( // process) lives inside the try so the catch can tear down whatever was already created. In // particular, if listener.Start() fails to bind or the process fails to start, the listener is // stopped rather than leaked. - listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener = MtpServerConnector.StartLoopbackListener(out int port); LaunchCommand launch = BuildLaunch(source, port); string fileName = launch.FileName; @@ -187,49 +230,28 @@ public static async Task StartAsync( // Drain stdout so the child never blocks on a full pipe (banner, diagnostics). process.BeginOutputReadLine(); -#if NET8_0_OR_GREATER - acceptTask = listener.AcceptTcpClientAsync(cancellationToken).AsTask(); -#else - acceptTask = listener.AcceptTcpClientAsync(); -#endif - // Wait for the app to dial back, but poll the process alongside the accept: if the child exits // early (bad arguments, startup crash) we fail fast with its exit code + captured stderr instead // of blocking the full ConnectionTimeout and then reporting a misleading timeout. Polling // process.HasExited (rather than racing the accept against Process.Exited) keeps this free of a // TaskCompletionSource ordering race. - var connectStopwatch = Stopwatch.StartNew(); - while (!acceptTask.IsCompleted) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (process.HasExited) - { - throw new MtpServerConnectionClosedException( - $"The Microsoft.Testing.Platform application '{source}' exited with code {process.ExitCode} before connecting back. {GetStandardError(standardError)}"); - } - - if (connectStopwatch.Elapsed >= options.ConnectionTimeout) - { - throw new MtpServerConnectionClosedException( - $"The Microsoft.Testing.Platform application '{source}' did not connect back within {options.ConnectionTimeout.TotalSeconds:N0}s. {GetStandardError(standardError)}"); - } - - var delayTask = Task.Delay(ProcessExitPollInterval, cancellationToken); - _ = await Task.WhenAny(acceptTask, delayTask).ConfigureAwait(false); - } - - cancellationToken.ThrowIfCancellationRequested(); - acceptedClient = await acceptTask.ConfigureAwait(false); - acceptedClient.NoDelay = true; - NetworkStream stream = acceptedClient.GetStream(); - - var handler = new TcpMessageHandler(acceptedClient, stream, stream, formatter); - var connection = new MtpJsonRpcConnection(handler, logger); + Process startedProcess = process; + acceptedClient = await MtpServerConnector.AcceptAsync( + listener, + () => startedProcess.HasExited + ? new MtpServerConnectionClosedException( + $"The Microsoft.Testing.Platform application '{source}' exited with code {startedProcess.ExitCode} before connecting back. {GetStandardError(standardError)}") + : null, + () => new MtpServerConnectionClosedException( + $"The Microsoft.Testing.Platform application '{source}' did not connect back within {options.ConnectionTimeout.TotalSeconds:N0}s. {GetStandardError(standardError)}"), + options.ConnectionTimeout, + serverCompletion: null, + cancellationToken).ConfigureAwait(false); // NOTE: the read loop is intentionally NOT started here. The owner (MtpServerClient) wires its // notification/server-request handlers first and then calls Connection.Start(), so no server -> // client message can slip past before the handlers are attached. + MtpJsonRpcConnection connection = MtpServerConnector.CreateConnection(acceptedClient, formatter, logger); return new MtpServerProcess(listener, process, acceptedClient, connection, standardError, logger); } catch @@ -240,34 +262,11 @@ public static async Task StartAsync( process.Dispose(); } - // If the accept has not produced a socket we own yet, guard against a late dial-back leaking a - // connected socket: hand the still-pending accept a continuation that disposes any socket it - // eventually yields (or observes its fault so the task is not left unobserved when SafeStop - // faults it). When acceptedClient is already set we own it and dispose it directly below. - if (acceptedClient is null && acceptTask is not null) - { - _ = acceptTask.ContinueWith( - static t => - { - if (t.Status == TaskStatus.RanToCompletion) - { - t.Result.Dispose(); - } - else - { - _ = t.Exception; - } - }, - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); - } - acceptedClient?.Dispose(); if (listener is not null) { - SafeStop(listener, logger); + MtpServerConnector.SafeStop(listener, logger); } throw; @@ -312,6 +311,12 @@ private static Process CreateProcess(ProcessStartInfo startInfo, StringBuilder s internal static LaunchCommand BuildLaunch(string source, int port) { + // Deliberately NOT composed from MtpServerConnector.BuildInProcessServerArguments: this string is the + // command line an already-shipped API hands to already-shipped test applications, so it is kept + // byte-identical rather than gaining the in-process path's explicit protocol and host. The server + // maps its 'localhost' default to IPAddress.Loopback, which is what the listener binds, so the two + // forms are equivalent on the wire; only the in-process array states them explicitly because an + // embedded host reads it as the documentation of what the client asked for. string serverArgs = $"{ServerArgument} {ClientPortArgument} {port} {NoBannerArgument}"; string workingDirectory = Path.GetDirectoryName(source) ?? Directory.GetCurrentDirectory(); string extension = Path.GetExtension(source); @@ -425,20 +430,9 @@ private static bool IsWindows() => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); #endif - private static void SafeStop(TcpListener listener, IMtpClientLogger logger) - { - try - { - listener.Stop(); - } - catch (SocketException ex) - { - logger.SafeLog(MtpClientLogLevel.Debug, $"Stopping the TCP listener threw: {ex}"); - } - } - - private static void SafeKill(Process process, IMtpClientLogger logger) + private static bool SafeKill(Process process, IMtpClientLogger logger) { + bool killed = false; try { if (!process.HasExited) @@ -450,6 +444,7 @@ private static void SafeKill(Process process, IMtpClientLogger logger) // server spawned are left to the OS. This is best-effort teardown on that platform. process.Kill(); #endif + killed = true; // Block (bounded) for the OS to finish tearing the process down so a caller can immediately // delete the application directory without racing a file lock on the still-exiting executable. @@ -460,29 +455,70 @@ private static void SafeKill(Process process, IMtpClientLogger logger) { logger.SafeLog(MtpClientLogLevel.Debug, $"Killing the MTP server process threw: {ex}"); } + + return killed; } + /// + /// Kills the launched process and releases the transport, waiting synchronously for the bounded kill so a + /// caller can immediately delete the application directory. + /// + /// + /// Joins the one teardown rather than starting a second, so a that races or follows + /// still returns only once the process has actually gone. + /// public void Dispose() +#pragma warning disable VSTHRD002 // Synchronously waiting on tasks - this IS the synchronous disposal path; ShutdownAsync is the awaitable one. + => StartShutdownAsync().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 + + private Task StartShutdownAsync() { - if (Interlocked.Exchange(ref _disposed, 1) == 1) + lock (_shutdownLock) { - return; + return _shutdown ??= Task.Run(ShutdownCore); } + } - // Dispose the connection first (cancels the read loop, disposes the handler -> socket/streams). - Connection.Dispose(); - + private void ShutdownCore() + { try { - _client.Dispose(); + // Dispose the connection first (cancels the read loop, disposes the handler -> socket/streams). + Connection.Dispose(); + + try + { + _client.Dispose(); + } + catch (SocketException ex) + { + _logger.SafeLog(MtpClientLogLevel.Debug, $"Disposing the accepted client socket threw: {ex}"); + } + + MtpServerConnector.SafeStop(_listener, _logger); + + // Capture before killing, so an application that already exited on its own reports its real exit + // code rather than the kill's, and before Dispose(), after which the Process cannot be read. + int? exitCode = TryReadExitCode(); + Volatile.Write(ref _capturedExitCode, exitCode is int captured ? captured : NoExitCode); + bool killed = SafeKill(_process, _logger); + + // Preserve the race where the process exits naturally between the first read and SafeKill's + // HasExited check, but never publish the operating system's forced-termination status as though + // it were an application-returned exit code. + if (exitCode is null && !killed && TryReadExitCode() is int racedExitCode) + { + Volatile.Write(ref _capturedExitCode, racedExitCode); + } + + _process.Dispose(); } - catch (SocketException ex) + catch (Exception ex) { - _logger.SafeLog(MtpClientLogLevel.Debug, $"Disposing the accepted client socket threw: {ex}"); + // The shared teardown task must never fault: every current and future Dispose/ShutdownAsync + // caller awaits this one task, so a fault here would throw from every subsequent disposal. + _logger.SafeLog(MtpClientLogLevel.Error, $"Tearing down the MTP server process threw: {ex}"); } - - SafeStop(_listener, _logger); - SafeKill(_process, _logger); - _process.Dispose(); } } diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/PACKAGE.md b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/PACKAGE.md index 081381210e..f224044cd5 100644 --- a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/PACKAGE.md @@ -16,6 +16,9 @@ compiled (as `internal` types) into your own assembly. That means: - `MtpServerClient` / `IMtpServerClient` — launch an MTP test app in server mode and drive it: `InitializeAsync`, `DiscoverTestsAsync`, `RunTestsAsync`, `ExitAsync`, plus a `TestNodesUpdated` event. +- Two launch paths: an **external process** (`LaunchAsync(path)`) for IDE and desktop tooling, and an + **in-process host** (`LaunchInProcessAsync(callback)`) for embedded runners (MAUI, Android/iOS test + apps) that cannot spawn a child process. - The launch/transport layer (loopback TCP listener the app dials back to, LSP-style `Content-Length` framing) and the strongly-typed protocol records. @@ -41,6 +44,75 @@ MtpRunResult result = await client.RunTestsAsync(); await client.ExitAsync(); ``` +### Embedded hosts (no child process) + +`LaunchInProcessAsync` runs the test application in **your own process**. You supply only "how to run +the application"; the client still owns the loopback listener, the server-mode arguments, the connect +race, the serializer/formatter/transport setup and the shutdown sequence: + +```csharp +using IMtpServerClient client = await MtpServerClient.LaunchInProcessAsync( + async (serverArgs, token) => + { + // serverArgs is the complete server-mode argument array + // (--server jsonrpc --client-host … --client-port … --no-banner). + // Forward it verbatim; do not filter or reorder it. + ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(serverArgs); + builder.AddMSTest(() => testAssemblies); + using ITestApplication app = await builder.BuildAsync(); + return await app.RunAsync(); + }, + options, + cancellationToken); + +await client.InitializeAsync(); +await client.DiscoverTestsAsync(); +await client.RunTestsAsync(); +await client.ExitAsync(); + +// Non-blocking teardown. The trailing Dispose from the `using` is then a no-op. +await client.ShutdownAsync(); +Console.WriteLine(client.ServerExitCode); +``` + +Things to know: + +- **Ownership.** The returned client owns the hosted application. Tearing it down closes the transport — + which is how a server-mode application is asked to stop — and then waits for your callback. Call + `ExitAsync` first for a protocol-level shutdown. +- **Prefer `ShutdownAsync()` over `Dispose()`.** `Dispose()` performs that wait **synchronously on the + calling thread**, so on a platform with a responsiveness watchdog (Android ANR, the iOS watchdog) it can + trip it. `await client.ShutdownAsync()` does the same work without blocking; a following `Dispose()` + returns immediately. Both are idempotent. +- **Bounded shutdown.** Teardown waits at most `MtpServerClientOptions.ServerShutdownTimeout` + (default 30 seconds), then cancels the token passed to your callback and waits a further fixed + 5 seconds. A callback still running after that is abandoned rather than hanging your app; its failure + is reported through `MtpServerClientOptions.Logger`. A *failed launch* skips the graceful wait + entirely — the callback's token is canceled immediately and only the fixed 5-second grace applies — + so an unwinding caller never waits for `ServerShutdownTimeout`. +- **Cancellation.** The `cancellationToken` passed to `LaunchInProcessAsync` scopes the *launch* only. + Once the client exists, canceling it no longer affects the hosted application. Cancellation is bounded + rather than immediate: the unwind waits up to 5 seconds for the callback to stop before abandoning it. + Per-request cancellation is unchanged — canceling a `RunTestsAsync`/`DiscoverTestsAsync` token sends + `$/cancelRequest`. +- **Exit code.** `client.ServerExitCode` carries the value your callback returned (typically + `TestApplication.RunAsync`'s exit code) once teardown has completed. +- **Failures before connection.** If the callback throws, is canceled, or returns before dialing back, + the launch fails with `MtpServerConnectionClosedException` and your exception is preserved as the + inner exception (instead of surfacing as a misleading connection timeout). +- **Failures after connection.** `ShutdownAsync` finishes tearing down the connection and then rethrows the + callback's original fault or self-cancellation. Cancellation requested by teardown itself is expected and is + not rethrown. `Dispose` remains non-throwing and reports callback failures through + `MtpServerClientOptions.Logger`, so cleanup cannot mask an exception already leaving your code. +- **Threading.** The callback is invoked on the thread pool, so the launch never blocks the caller and + the callback never inherits the caller's synchronization context. There is no synchronous + `LaunchInProcess` overload on purpose. +- **`EnvironmentVariables` is ignored** on this path: the application shares your process's + environment. Set the variables before starting the host. +- **Platform limits.** Both launch paths use loopback TCP. On browser/WASM there is no listening + socket, so `LaunchInProcessAsync` throws `PlatformNotSupportedException`. This package does **not** + enable server-mode testing in the browser. + ## Consumer requirements Because the source is compiled into your assembly, your project must provide the ambient pieces the diff --git a/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/MtpServerClientAcceptanceTests.cs b/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/MtpServerClientAcceptanceTests.cs index 773e3a97ac..f794110bd9 100644 --- a/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/MtpServerClientAcceptanceTests.cs +++ b/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/MtpServerClientAcceptanceTests.cs @@ -3,6 +3,8 @@ extern alias serverclient; +using System.Diagnostics; + using Microsoft.Testing.Platform.Acceptance.IntegrationTests; using serverclient::Microsoft.Testing.Platform.ServerMode.Client; @@ -94,9 +96,73 @@ public async Task DiscoverAndRun_ViaSourcePackageClient_ReportsExpectedTestNode( $"Expected exactly one passed action node named '{ExpectedTestDisplayName}'. Collected: {Describe(snapshot)}"); await client.ExitAsync(cancellationToken); + await WaitForServerExitAsync(client, cancellationToken); + + // The non-blocking teardown on the external-process path: it shares one teardown with Dispose, so + // the trailing Dispose from the using block joins the same (already finished) work. + await client.ShutdownAsync(); + Assert.AreEqual(0, client.ServerExitCode, "The launched process must have exited cleanly once shutdown completed."); + } + } + + [TestMethod] + [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] + public async Task ShutdownAsync_WhileExternalTeardownBlocks_ReturnsImmediatelyAndHidesForcedExitCode(string tfm) + { + CancellationToken cancellationToken = TestContext.CancellationToken; + string source = TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, tfm).FullName; + MtpServerClientOptions options = CreateOptions(); + options.EnvironmentVariables["MTP_SERVER_BLOCK_AFTER_RUN"] = "1"; + + using var client = MtpServerClient.Launch(source, options); + _ = await client.InitializeAsync(cancellationToken); + + var handlerEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseHandler = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.TestNodesUpdated += (_, _) => + { + handlerEntered.TrySetResult(true); + releaseHandler.Task.GetAwaiter().GetResult(); + }; + + Task discover = client.DiscoverTestsAsync(cancellationToken); + await handlerEntered.Task.WaitAsync(cancellationToken); + + try + { + var stopwatch = Stopwatch.StartNew(); + Task shutdown = client.ShutdownAsync(); + stopwatch.Stop(); + + Assert.IsLessThan( + TimeSpan.FromSeconds(2), + stopwatch.Elapsed, + $"Calling ShutdownAsync took {stopwatch.Elapsed.TotalMilliseconds:F0} ms; external-process teardown must run asynchronously."); + + _ = await Assert.ThrowsExactlyAsync(() => discover); + + // Keep the handler blocked until teardown finishes so this exercises the connection's bounded + // read-loop wait rather than only proving that Task.Run returns quickly for an immediate dispose. + await shutdown; + Assert.IsNull(client.ServerExitCode, "Forced process termination must not surface the operating system's kill status as an application exit code."); + } + finally + { + _ = releaseHandler.TrySetResult(true); } } + private static async Task WaitForServerExitAsync(MtpServerClient client, CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + while (client.ServerExitCode is null && stopwatch.Elapsed < TimeSpan.FromSeconds(30)) + { + await Task.Delay(100, cancellationToken); + } + + Assert.IsNotNull(client.ServerExitCode, "The launched process did not exit after receiving the exit notification."); + } + private static string Describe(IReadOnlyList nodes) => nodes.Count == 0 ? "" @@ -164,7 +230,13 @@ public static async Task Main(string[] args) ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args); builder.RegisterTestFramework(_ => new Capabilities(), (_, __) => new DummyTestFramework()); using ITestApplication app = await builder.BuildAsync(); - return await app.RunAsync(); + int exitCode = await app.RunAsync(); + if (Environment.GetEnvironmentVariable("MTP_SERVER_BLOCK_AFTER_RUN") == "1") + { + await Task.Delay(System.Threading.Timeout.Infinite); + } + + return exitCode; } } diff --git a/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/MtpServerClientInProcessAcceptanceTests.cs b/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/MtpServerClientInProcessAcceptanceTests.cs new file mode 100644 index 0000000000..222f55c775 --- /dev/null +++ b/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/MtpServerClientInProcessAcceptanceTests.cs @@ -0,0 +1,322 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform.Acceptance.IntegrationTests; +using Microsoft.Testing.Platform.Acceptance.IntegrationTests.Helpers; + +namespace MSTest.Acceptance.IntegrationTests; + +/// +/// End-to-end acceptance test for MtpServerClient.LaunchInProcessAsync, the embedded-host launch path +/// of the source-only Microsoft.Testing.Platform.ServerMode.Client.Sources package. +/// +/// +/// +/// This is the scenario the API exists for and it cannot be faked from the test host: one single process is +/// at the same time the embedded host (it compiles the packed source-only client and drives it) and the +/// Microsoft.Testing.Platform application (a real TestApplication with real MSTest registered over a +/// real [TestClass]). No Process.Start is involved anywhere — exactly the constraint a MAUI / +/// Android / iOS runner works under. +/// +/// +/// The generated app reports its verdict only through its exit code plus a few parseable marker lines, so +/// this test stays independent of the app's console formatting. Because it consumes the packed client +/// package, the repository must have been built with -pack first; otherwise the test fails with a +/// hint. The sibling covers the external-process launch path +/// against the same kind of real application. +/// +/// +[TestClass] +public sealed class MtpServerClientInProcessAcceptanceTests : AcceptanceTestBase +{ + private const string AssetName = "MtpServerClientInProcessHost"; + private const string PackageId = "Microsoft.Testing.Platform.ServerMode.Client.Sources"; + + private const string Sources = """ +#file InProcessHost/InProcessHost.csproj + + + $TargetFramework$ + Exe + true + enable + preview + + true + false + false + + + + + + + + +#file InProcessHost/UnitTests.cs +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public sealed class UnitTests +{ + [TestMethod] + public void TestMethod1() => Assert.IsTrue(true); +} + +#file InProcessHost/Program.cs +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Testing.Platform.Builder; +using Microsoft.Testing.Platform.ServerMode.Client; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +internal static class Program +{ + private const string ExpectedDisplayName = "TestMethod1"; + + public static async Task Main(string[] args) + { + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(120)); + CancellationToken cancellationToken = cts.Token; + + try + { + // One session, driven statefully: discover then run over the same in-process connection. + List updates = new(); + object gate = new(); + + using IMtpServerClient client = await MtpServerClient.LaunchInProcessAsync( + RunTestApplicationAsync, + CreateOptions(), + cancellationToken); + + client.TestNodesUpdated += (_, e) => + { + lock (gate) + { + updates.AddRange(e.Changes); + } + }; + + MtpServerCapabilities capabilities = await client.InitializeAsync(cancellationToken); + if (!capabilities.SupportsDiscovery) + { + Console.Error.WriteLine("INPROCESSHOST: the server did not advertise discovery support."); + return 1; + } + + int currentProcessId; + using (System.Diagnostics.Process current = System.Diagnostics.Process.GetCurrentProcess()) + { + currentProcessId = current.Id; + } + + if (capabilities.ServerProcessId != currentProcessId) + { + Console.Error.WriteLine("INPROCESSHOST: the server reported a different process id, so it was not hosted in process."); + return 1; + } + + Console.WriteLine("INPROCESSHOST: SAMEPROCESS " + client.ProcessId); + + await client.DiscoverTestsAsync(cancellationToken); + if (!Expect(updates, gate, "discovered")) + { + return 1; + } + + Console.WriteLine("INPROCESSHOST: DISCOVERED " + ExpectedDisplayName); + + await client.RunTestsAsync(cancellationToken); + if (!Expect(updates, gate, "passed")) + { + return 1; + } + + Console.WriteLine("INPROCESSHOST: EXECUTED " + ExpectedDisplayName); + + await client.ExitAsync(cancellationToken); + + // The non-blocking teardown: closes the transport and awaits the hosted application. The trailing + // Dispose from the using block is then a no-op. + await client.ShutdownAsync(); + + if (client.ServerExitCode != 0) + { + Console.Error.WriteLine("INPROCESSHOST: the hosted application exited with " + client.ServerExitCode); + return 1; + } + + Console.WriteLine("INPROCESSHOST: EXITCODE " + client.ServerExitCode); + Console.WriteLine("INPROCESSHOST: OK"); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine("INPROCESSHOST: FAILED " + ex); + return 1; + } + } + + // The whole point of the API: the caller only says "how to run the application". The client owns the + // listener, the argument array, the connect race, the transport and the shutdown. + private static async Task RunTestApplicationAsync(string[] serverArguments, CancellationToken cancellationToken) + { + ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(serverArguments); + builder.AddMSTest(() => new[] { Assembly.GetEntryAssembly()! }); + using ITestApplication app = await builder.BuildAsync(); + return await app.RunAsync(); + } + + private static bool Expect(List updates, object gate, string executionState) + { + List snapshot; + lock (gate) + { + snapshot = updates.ToList(); + } + + int count = snapshot.Count(n => n.NodeType == "action" && n.DisplayName == ExpectedDisplayName && n.ExecutionState == executionState); + if (count == 1) + { + return true; + } + + Console.Error.WriteLine(FormattableString.Invariant( + $"INPROCESSHOST: expected exactly one '{executionState}' {ExpectedDisplayName}, got {count}. {Describe(snapshot)}")); + return false; + } + + private static string Describe(IReadOnlyList nodes) + => nodes.Count == 0 + ? "" + : string.Join(", ", nodes.Select(n => "[uid=" + n.Uid + ", name=" + n.DisplayName + ", type=" + n.NodeType + ", state=" + n.ExecutionState + "]")); + + private static MtpServerClientOptions CreateOptions() + => new() + { + ClientName = "InProcessHost", + // The session serves discover and run over one connection. + IsStateful = true, + ConnectionTimeout = TimeSpan.FromSeconds(60), + ServerShutdownTimeout = TimeSpan.FromSeconds(30), + Logger = new DelegateMtpClientLogger( + (level, message) => Console.WriteLine("INPROCESSHOST: LOG " + level + " " + message)), + }; +} +"""; + + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + public async Task InProcessHost_DiscoversAndRunsItsOwnMSTestNodes_WithoutStartingAProcess() + { + string patchedSources = Sources + .PatchCodeWithReplace("$TargetFramework$", TargetFrameworks.NetCurrent) + .PatchCodeWithReplace("$ServerClientSourceVersion$", ResolveServerClientSourceVersion()) + .PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion) + .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion); + + using TestAsset testAsset = await TestAsset.GenerateAssetAsync(AssetName, patchedSources); + + // Restore into a throwaway packages folder so the freshly packed client is always used; a stale + // cached copy of the same dev version would otherwise make this gate pass vacuously. + string isolatedPackages = Path.Combine(testAsset.TargetAssetPath, ".nuget-packages"); + var environmentVariables = new Dictionary(StringComparer.Ordinal) + { + ["NUGET_PACKAGES"] = isolatedPackages, + }; + + DotnetMuxerResult build = await DotnetCli.RunAsync( + $"build {testAsset.TargetAssetPath}/InProcessHost -c {Constants.BuildConfiguration}", + environmentVariables: environmentVariables, + failIfReturnValueIsNotZero: false, + cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual( + 0, + build.ExitCode, + $"The embedded host failed to compile the injected source. Build output:{Environment.NewLine}{build.StandardOutput}{Environment.NewLine}{build.StandardError}"); + + var testHost = TestHost.LocateFrom( + Path.Combine(testAsset.TargetAssetPath, "InProcessHost"), + "InProcessHost", + TargetFrameworks.NetCurrent, + buildConfiguration: Enum.Parse(Constants.BuildConfiguration)); + + // Run the apphost directly rather than through TestHost.ExecuteAsync: this app owns its Main and is + // the embedded HOST, so it does not accept the platform flags that helper injects. + using var command = new CommandLine(); + + // The trailing space matters: CommandLine's quoted-path parser reads the arguments as everything after + // the closing quote plus one separator, so a bare quoted path with nothing after it is out of range. + int exitCode = await command.RunAsyncAndReturnExitCodeAsync( + $"\"{testHost.FullName}\" ", + environmentVariables: CreateChildEnvironment(), + cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual( + 0, + exitCode, + $"The embedded host did not complete successfully.{Environment.NewLine}STD:{Environment.NewLine}{command.StandardOutput}{Environment.NewLine}ERR:{Environment.NewLine}{command.ErrorOutput}"); + Assert.Contains("INPROCESSHOST: SAMEPROCESS", command.StandardOutput); + Assert.Contains("INPROCESSHOST: DISCOVERED TestMethod1", command.StandardOutput); + Assert.Contains("INPROCESSHOST: EXECUTED TestMethod1", command.StandardOutput); + Assert.Contains("INPROCESSHOST: EXITCODE 0", command.StandardOutput); + Assert.Contains("INPROCESSHOST: OK", command.StandardOutput); + } + + private static Dictionary CreateChildEnvironment() + { + var environment = new Dictionary(StringComparer.Ordinal); + + // Neutralize inherited environment that would otherwise change the hosted application's behavior + // (dump generation, telemetry opt-out, banners, ambient agent detection, ...). These are added on top + // of the inherited environment, so overwriting each well-known variable with an empty value clears it. + foreach (string variable in WellKnownEnvironmentVariables.ToSkipEnvironmentVariables) + { + environment[variable] = string.Empty; + } + + string dotnetRoot = $"{RootFinder.Find()}/.dotnet"; + environment["DOTNET_ROOT"] = dotnetRoot; + environment["DOTNET_INSTALL_DIR"] = dotnetRoot; + environment["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"; + environment["DOTNET_MULTILEVEL_LOOKUP"] = "0"; + environment["DOTNET_ROLL_FORWARD"] = "Major"; + environment["TESTINGPLATFORM_EXIT_PROCESS_ON_UNHANDLED_EXCEPTION"] = "0"; + + return environment; + } + + private static string ResolveServerClientSourceVersion() + { + const string prefix = PackageId + "."; + const string extension = ".nupkg"; + + // Require exactly one match: picking an arbitrary lingering build could compile against a stale + // package and defeat this gate even with an isolated NuGet cache. + string[] matches = Directory + .GetFiles(Constants.ArtifactsPackagesShipping, prefix + "*" + extension, SearchOption.TopDirectoryOnly) + .Where(path => !path.EndsWith(".symbols.nupkg", StringComparison.OrdinalIgnoreCase)) + .Select(path => (path, name: Path.GetFileName(path))) + .Where(tuple => tuple.name.Length > prefix.Length && char.IsDigit(tuple.name[prefix.Length])) + .Select(tuple => tuple.path) + .ToArray(); + + if (matches.Length != 1) + { + throw new InvalidOperationException( + $"Expected exactly one packed '{PackageId}' package in '{Constants.ArtifactsPackagesShipping}', " + + $"but found {matches.Length}. Build with -pack first and clear any stale builds from the shipping folder."); + } + + string fileName = Path.GetFileName(matches[0]); + return fileName.Substring(prefix.Length, fileName.Length - prefix.Length - extension.Length); + } +} diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MtpServerClientSourcePackageConsumerTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MtpServerClientSourcePackageConsumerTests.cs index af8107027e..244bcb2d7d 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MtpServerClientSourcePackageConsumerTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MtpServerClientSourcePackageConsumerTests.cs @@ -80,6 +80,7 @@ internal static async Task DriveAsync(string source, CancellationToken cancellat DebuggerProvider = true, IsStateful = true, ConnectionTimeout = TimeSpan.FromSeconds(30), + ServerShutdownTimeout = TimeSpan.FromSeconds(30), Logger = logger, }; options.EnvironmentVariables["EXAMPLE"] = "1"; @@ -97,6 +98,7 @@ internal static async Task DriveAsync(string source, CancellationToken cancellat Console.WriteLine(capabilities.ServerName ?? "unknown"); Console.WriteLine(client.Capabilities?.MultiRequestSupport ?? false); Console.WriteLine(client.ProcessId); + Console.WriteLine(client.ServerExitCode ?? -1); await client.DiscoverTestsAsync(cancellationToken); await client.DiscoverTestsAsync(new[] { "uid" }, cancellationToken); @@ -111,12 +113,31 @@ internal static async Task DriveAsync(string source, CancellationToken cancellat await client.RunTestsAsync(new[] { "uid" }, cancellationToken); await client.RunTestsWithFilterAsync("/*/*/*/*", cancellationToken); await client.ExitAsync(cancellationToken); + await client.ShutdownAsync(); // Referencing the platform assembly alongside the source package must bind these two public // types to the same assembly. Injected protocol types live in a package-private namespace. _ = new TestNodeUidListFilter(new[] { new TestNodeUid("uid") }); } + // The embedded-host launch path: no Process.Start, the caller only supplies "how to run the app". + internal static async Task DriveInProcessAsync(CancellationToken cancellationToken) + { + using IMtpServerClient client = await MtpServerClient.LaunchInProcessAsync( + (string[] serverArgs, CancellationToken serverToken) => + { + Console.WriteLine(string.Join(" ", serverArgs)); + return Task.FromResult(serverToken.IsCancellationRequested ? 1 : 0); + }, + new MtpServerClientOptions(), + cancellationToken); + + Console.WriteLine(client.ProcessId); + await client.ExitAsync(cancellationToken); + await client.ShutdownAsync(); + Console.WriteLine(client.ServerExitCode ?? -1); + } + private static void OnTestNodesUpdated(object? sender, MtpTestNodeUpdateEventArgs e) { Console.WriteLine(e.RunId); diff --git a/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/FakeMtpServer.cs b/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/FakeMtpServer.cs index d4fec7b565..0414637314 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/FakeMtpServer.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/FakeMtpServer.cs @@ -39,7 +39,7 @@ namespace Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests; /// internal sealed class FakeMtpServer : IDisposable { - private readonly TcpListener _listener; + private readonly TcpListener? _listener; private readonly IMessageFormatter _formatter; private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly List _receivedNotifications = []; @@ -49,6 +49,7 @@ internal sealed class FakeMtpServer : IDisposable private readonly Dictionary> _pendingServerRequests = []; private readonly object _pendingServerRequestsLock = new(); private readonly TaskCompletionSource _handlerReady = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _disconnected = new(TaskCreationOptions.RunContinuationsAsynchronously); private TcpClient? _serverClient; private NetworkStream? _serverStream; @@ -60,26 +61,34 @@ public FakeMtpServer() _listener.Start(); Port = ((IPEndPoint)_listener.LocalEndpoint).Port; _formatter = FormatterUtilities.CreateFormatter(); - - InitializeResponse = new InitializeResponseArgs( - ProcessId: 4242, - ServerInfo: new ServerInfo("FakeMtpServer", "1.2.3"), - Capabilities: new ServerCapabilities(new ServerTestingCapabilities( - SupportsDiscovery: true, - MultiRequestSupport: true, - VSTestProviderSupport: false, - SupportsAttachments: true, - MultiConnectionProvider: false))) - { - ProtocolVersion = JsonRpcProtocolVersions.Current, - }; + InitializeResponse = CreateDefaultInitializeResponse(); _ = Task.Run(AcceptAndServeAsync); } - /// Gets the loopback port the fake server is listening on. + /// + /// Serves the protocol over a socket the fake server dialed itself. This is the shape an + /// MtpServerClient.LaunchInProcessAsync callback sees: the CLIENT listens and the server connects + /// back, which is the opposite of the parameterless constructor's listen-and-accept mode. + /// + private FakeMtpServer(TcpClient dialedSocket) + { + _formatter = FormatterUtilities.CreateFormatter(); + InitializeResponse = CreateDefaultInitializeResponse(); + + _ = Task.Run(() => ServeAsync(dialedSocket)); + } + + /// Gets the loopback port the fake server is listening on (listen mode only). public int Port { get; } + /// + /// Gets a task that completes when the connection to the client is gone (the client disposed it, or the + /// read loop ended). An in-process server callback awaits this to model a real test application, which + /// runs until its server-mode session ends. + /// + public Task Disconnected => _disconnected.Task; + /// Gets or sets the response returned for an initialize request. public InitializeResponseArgs InitializeResponse { get; set; } @@ -136,6 +145,26 @@ public IReadOnlyList ReceivedRequests } } + /// + /// Dials back to a client's loopback listener and serves the protocol on that socket. This is what an + /// in-process MTP application does when it is handed --client-host/--client-port. + /// + public static FakeMtpServer ConnectBackTo(string host, int port) + { + var tcp = new TcpClient(); + try + { + tcp.Connect(host, port); + tcp.NoDelay = true; + return new FakeMtpServer(tcp); + } + catch + { + tcp.Dispose(); + throw; + } + } + /// /// Connects a fresh to this server over loopback TCP. The client's /// constructor starts its read loop, so the returned client is immediately live. @@ -356,7 +385,7 @@ public void Dispose() { try { - _listener.Stop(); + _listener?.Stop(); } catch (Exception) { @@ -382,6 +411,7 @@ public void Dispose() } _writeLock.Dispose(); + _ = _disconnected.TrySetResult(null); } private Task SendTestNodeAsync(Guid runId, string uid, string displayName, PropertyBag properties) @@ -398,21 +428,41 @@ private Task SendTestNodeAsync(Guid runId, string uid, string displayName, Prope new TestNodeStateChangedEventArgs(runId, [change]))); } + private static InitializeResponseArgs CreateDefaultInitializeResponse() + => new( + ProcessId: 4242, + ServerInfo: new ServerInfo("FakeMtpServer", "1.2.3"), + Capabilities: new ServerCapabilities(new ServerTestingCapabilities( + SupportsDiscovery: true, + MultiRequestSupport: true, + VSTestProviderSupport: false, + SupportsAttachments: true, + MultiConnectionProvider: false))) + { + ProtocolVersion = JsonRpcProtocolVersions.Current, + }; + private async Task AcceptAndServeAsync() { TcpClient socket; try { - socket = await _listener.AcceptTcpClientAsync().ConfigureAwait(false); + socket = await _listener!.AcceptTcpClientAsync().ConfigureAwait(false); } catch (Exception) { // The listener was stopped before a client connected (e.g. the test finished). Leave the // handler-ready task pending; nothing will await it. + _ = _disconnected.TrySetResult(null); return; } socket.NoDelay = true; + await ServeAsync(socket).ConfigureAwait(false); + } + + private async Task ServeAsync(TcpClient socket) + { _serverClient = socket; _serverStream = socket.GetStream(); var handler = new TcpMessageHandler(socket, _serverStream, _serverStream, _formatter); @@ -426,6 +476,10 @@ private async Task AcceptAndServeAsync() { // The connection was torn down (client disposed or the test ended). Nothing to do. } + finally + { + _ = _disconnected.TrySetResult(null); + } } private async Task ReadLoopAsync(TcpMessageHandler handler) diff --git a/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/MtpServerClientInProcessTests.cs b/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/MtpServerClientInProcessTests.cs new file mode 100644 index 0000000000..a4051115c0 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/MtpServerClientInProcessTests.cs @@ -0,0 +1,942 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Diagnostics; + +using Microsoft.Testing.Platform.ServerMode; +using Microsoft.Testing.Platform.ServerMode.Client; + +namespace Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests; + +/// +/// Tests for : the launch path for embedded hosts that +/// cannot spawn a child process. The callback plays the part of the hosted MTP application — it parses the +/// server-mode arguments the client generated, dials back to the client's loopback listener, and serves the +/// real wire protocol through . +/// +/// +/// Everything below the callback is production code: the client owns the listener, the argument array, the +/// connect race, the serializer/formatter/transport setup and the shutdown sequence. That is exactly what an +/// embedded host must not have to reimplement. +/// +[TestClass] +public sealed class MtpServerClientInProcessTests +{ + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + + /// + /// The id of the process the tests run in. An in-process host runs the application here, so this is also + /// the id the client and the server-mode handshake must report. + /// + private static readonly int CurrentProcessId = GetCurrentProcessId(); + + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + public async Task LaunchInProcessAsync_PassesCompleteServerModeArguments() + { + using var server = new InProcessServerFixture(); + + using MtpServerClient client = await LaunchAsync(server); + + string[] arguments = server.Arguments; + Assert.AreSequenceEqual( + new[] { "--server", "jsonrpc", "--client-host", "127.0.0.1", "--client-port" }, + arguments.Take(5), + $"The client must hand the callback a complete, ordered server-mode argument array. Actual: {string.Join(" ", arguments)}"); + Assert.IsTrue( + int.TryParse(arguments[5], out int port) && port > 0, + $"'--client-port' must carry the client's listener port. Actual: {string.Join(" ", arguments)}"); + Assert.AreEqual("--no-banner", arguments[6]); + Assert.HasCount(7, arguments); + } + + [TestMethod] + public async Task LaunchInProcessAsync_DrivesInitializeDiscoverRunAndExit() + { + using var server = new InProcessServerFixture(); + var discoverRunId = Guid.NewGuid(); + var runRunId = Guid.NewGuid(); + + using MtpServerClient client = await LaunchAsync(server); + List updates = []; + object gate = new(); + client.TestNodesUpdated += (_, e) => + { + lock (gate) + { + updates.AddRange(e.Changes); + } + }; + + MtpServerCapabilities capabilities = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)); + Assert.AreEqual("FakeMtpServer", capabilities.ServerName); + Assert.AreEqual(CurrentProcessId, client.ProcessId, "An in-process host runs the application in the caller's process."); + + await server.Value.SendDiscoveredTestNodeAsync(discoverRunId, "uid-1", "Test1"); + await WithTimeoutAsync(client.DiscoverTestsAsync(TestContext.CancellationToken)); + + await server.Value.SendPassedTestNodeAsync(runRunId, "uid-1", "Test1"); + MtpRunResult result = await WithTimeoutAsync(client.RunTestsAsync(TestContext.CancellationToken)); + Assert.IsEmpty(result.Artifacts); + + await WithTimeoutAsync(client.ExitAsync(TestContext.CancellationToken)); + await WithTimeoutAsync(server.Value.WaitForNotificationAsync(JsonRpcMethods.Exit, DefaultTimeout)); + + List snapshot; + lock (gate) + { + snapshot = [.. updates]; + } + + Assert.ContainsSingle(update => update.Uid == "uid-1" && update.ExecutionState == "discovered", snapshot); + Assert.ContainsSingle(update => update.Uid == "uid-1" && update.ExecutionState == "passed", snapshot); + } + + [TestMethod] + public async Task LaunchInProcessAsync_CallbackFaultsBeforeConnecting_PreservesCallbackException() + { + var callbackFailure = new InvalidOperationException("The embedded host could not start the application."); + + MtpServerConnectionClosedException exception = await AssertThrowsAsync( + () => MtpServerClient.LaunchInProcessAsync( + (_, _) => throw callbackFailure, + CreateOptions(), + TestContext.CancellationToken)); + + Assert.AreSame( + callbackFailure, + exception.InnerException, + "The callback's own exception must survive as the inner exception instead of being replaced by a timeout."); + } + + [TestMethod] + public async Task LaunchInProcessAsync_CallbackFaultsAsynchronouslyBeforeConnecting_PreservesCallbackException() + { + var callbackFailure = new InvalidOperationException("The application crashed during startup."); + + MtpServerConnectionClosedException exception = await AssertThrowsAsync( + () => MtpServerClient.LaunchInProcessAsync( + async (_, _) => + { + await Task.Yield(); + throw callbackFailure; + }, + CreateOptions(), + TestContext.CancellationToken)); + + Assert.AreSame(callbackFailure, exception.InnerException); + } + + [TestMethod] + public async Task LaunchInProcessAsync_CallbackBlocksBeforeReturningTask_ReturnsWithoutBlockingCaller() + { + using var releaseCallback = new ManualResetEventSlim(initialState: false); + var callbackEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var server = new InProcessServerFixture(); + + // StartNew intentionally does not unwrap the launch task: this test must observe whether invoking the + // async factory itself returns while the callback is blocked in its synchronous prefix. + Task> invocation = Task.Factory.StartNew( + () => MtpServerClient.LaunchInProcessAsync( + (arguments, serverToken) => + { + callbackEntered.TrySetResult(true); + releaseCallback.Wait(serverToken); + return server.RunAsync(arguments, serverToken); + }, + CreateOptions(), + TestContext.CancellationToken), + CancellationToken.None, + TaskCreationOptions.DenyChildAttach, + TaskScheduler.Default); + + await WithTimeoutAsync(callbackEntered.Task); + try + { + Task completed = await Task.WhenAny(invocation, Task.Delay(TimeSpan.FromSeconds(2), TestContext.CancellationToken)); + Assert.AreSame(invocation, completed, "Calling LaunchInProcessAsync must return its task without waiting for the callback's synchronous prefix."); + + Task launch = await invocation; + Assert.IsFalse(launch.IsCompleted, "The launch must still be waiting for the gated callback to connect."); + releaseCallback.Set(); + + using MtpServerClient client = await WithTimeoutAsync(launch); + _ = await WithTimeoutAsync(server.Connected); + _ = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)); + } + finally + { + releaseCallback.Set(); + } + } + + [TestMethod] + public async Task LaunchInProcessAsync_CallbackIsCanceledBeforeConnecting_PreservesCancellationException() + { + MtpServerConnectionClosedException exception = await AssertThrowsAsync( + () => MtpServerClient.LaunchInProcessAsync( + (_, _) => Task.FromCanceled(new CancellationToken(canceled: true)), + CreateOptions(), + TestContext.CancellationToken)); + + TaskCanceledException cancellationException = Assert.IsInstanceOfType(exception.InnerException); + Assert.IsNotNull(cancellationException.Task); + } + + [TestMethod] + public async Task LaunchInProcessAsync_CallbackExitsWithoutConnecting_FailsFastInsteadOfWaitingOutTheTimeout() + { + // The connect race probes "has the server stopped?" between accept polls. A server that stopped can + // no longer serve the connection, so the launch must report that precise failure (with its exit code) + // immediately rather than waiting for the connection timeout or handing back a dead client. + MtpServerClientOptions options = CreateOptions(); + options.ConnectionTimeout = TimeSpan.FromMinutes(5); + + var stopwatch = Stopwatch.StartNew(); + MtpServerConnectionClosedException exception = await AssertThrowsAsync( + () => MtpServerClient.LaunchInProcessAsync( + (_, _) => Task.FromResult(9), + options, + TestContext.CancellationToken)); + stopwatch.Stop(); + + Assert.Contains("exited with code 9", exception.Message); + Assert.IsLessThan( + TimeSpan.FromSeconds(20), + stopwatch.Elapsed, + "A server that stopped without connecting must fail fast, not wait out the connection timeout."); + } + + [TestMethod] + public async Task LaunchInProcessAsync_CallbackExitsBeforeConnecting_ReportsExitCode() + { + MtpServerConnectionClosedException exception = await AssertThrowsAsync( + () => MtpServerClient.LaunchInProcessAsync( + (_, _) => Task.FromResult(3), + CreateOptions(), + TestContext.CancellationToken)); + + Assert.Contains( + "exited with code 3", + exception.Message, + "A callback that returns without connecting back must be reported with its exit code, not as a timeout."); + } + + [TestMethod] + public async Task LaunchInProcessAsync_CallbackReturnsNullTask_Fails() + { + MtpServerConnectionClosedException exception = await AssertThrowsAsync( + () => MtpServerClient.LaunchInProcessAsync( +#pragma warning disable VSTHRD114 // Avoid returning null from a Task-returning method - this test deliberately supplies a misbehaving callback. + (_, _) => null!, +#pragma warning restore VSTHRD114 + CreateOptions(), + TestContext.CancellationToken)); + + Assert.IsInstanceOfType(exception.InnerException); + } + + [TestMethod] + public async Task LaunchInProcessAsync_NullCallback_Throws() + => await Assert.ThrowsExactlyAsync( + () => MtpServerClient.LaunchInProcessAsync(null!, CreateOptions(), TestContext.CancellationToken)); + + [TestMethod] + public async Task LaunchInProcessAsync_AlreadyCanceled_DoesNotInvokeCallback() + { + using var alreadyCanceled = new CancellationTokenSource(); + alreadyCanceled.Cancel(); + int invocations = 0; + + await Assert.ThrowsExactlyAsync( + () => MtpServerClient.LaunchInProcessAsync( + (_, _) => + { + _ = Interlocked.Increment(ref invocations); + return Task.FromResult(0); + }, + CreateOptions(), + alreadyCanceled.Token)); + + Assert.AreEqual(0, Volatile.Read(ref invocations), "A pre-canceled launch must not start the application."); + } + + [TestMethod] + public async Task LaunchInProcessAsync_CanceledWhileConnecting_CancelsTheCallbackToken() + { + using var cancellation = new CancellationTokenSource(); + var callbackObservedCancellation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var callbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // The callback never dials back, so the launch stays in the connect race until the caller cancels. + Task launch = MtpServerClient.LaunchInProcessAsync( + async (serverArguments, serverToken) => + { + callbackStarted.TrySetResult(true); + using CancellationTokenRegistration registration = serverToken.Register(() => callbackObservedCancellation.TrySetResult(true)); + await callbackObservedCancellation.Task; + return 0; + }, + CreateOptions(), + cancellation.Token); + + await WithTimeoutAsync(callbackStarted.Task); + cancellation.Cancel(); + + await Assert.ThrowsExactlyAsync(() => WithTimeoutAsync(launch)); + Assert.IsTrue( + await WithTimeoutAsync(callbackObservedCancellation.Task), + "A canceled launch must cancel the token handed to the callback so the abandoned application can stop."); + } + + [TestMethod] + public async Task LaunchInProcessAsync_ConnectionTimeoutElapses_FailsWithTimeoutMessage() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + MtpServerClientOptions options = CreateOptions(); + options.ConnectionTimeout = TimeSpan.FromMilliseconds(250); + + // Deliberately large: an abandoned launch must NOT spend the graceful shutdown timeout waiting for a + // callback that was never connected, so this value must not show up in the elapsed time below. + options.ServerShutdownTimeout = TimeSpan.FromMinutes(5); + + try + { + var stopwatch = Stopwatch.StartNew(); + MtpServerConnectionClosedException exception = await AssertThrowsAsync( + () => MtpServerClient.LaunchInProcessAsync( + async (_, serverToken) => + { + // Runs happily but never dials back: only the connection timeout can end the wait. + // It does observe the token, so the unwind does not pay the full cancellation grace. + using CancellationTokenRegistration registration = serverToken.Register(() => release.TrySetResult(true)); + await release.Task; + return 0; + }, + options, + TestContext.CancellationToken)); + stopwatch.Stop(); + + Assert.Contains("did not connect back within", exception.Message); + Assert.IsLessThan( + TimeSpan.FromSeconds(20), + stopwatch.Elapsed, + $"The failed launch took {stopwatch.Elapsed.TotalSeconds:N1}s; it must be bounded by the connection timeout plus the fixed cancellation grace, not by ServerShutdownTimeout."); + } + finally + { + _ = release.TrySetResult(true); + } + } + + [TestMethod] + public async Task Dispose_ClosesTransportAndAwaitsTheCallback() + { + using var server = new InProcessServerFixture(); + + MtpServerClient client = await LaunchAsync(server); + _ = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)); + Assert.IsFalse(server.Completion.IsCompleted, "The application must stay alive while the client is in use."); + Assert.IsNull(client.ServerExitCode, "The exit code is only known once the application has stopped."); + + client.Dispose(); + + Assert.IsTrue( + server.Completion.IsCompleted, + "Dispose must close the transport and wait for the hosted application before it returns."); + Assert.AreEqual(0, await server.Completion); + Assert.AreEqual(0, client.ServerExitCode, "The callback's exit code must be reachable after shutdown."); + } + + [TestMethod] + public async Task ShutdownAsync_ClosesTransportAndAwaitsTheCallback_WithoutBlocking() + { + using var server = new InProcessServerFixture(); + + using MtpServerClient client = await LaunchAsync(server); + _ = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)); + Assert.IsFalse(server.Completion.IsCompleted); + + await WithTimeoutAsync(client.ShutdownAsync()); + + Assert.IsTrue(server.Completion.IsCompleted, "ShutdownAsync must await the hosted application."); + Assert.AreEqual(0, client.ServerExitCode); + + // The documented pattern is ShutdownAsync inside a using block, so the trailing Dispose must be a + // cheap no-op rather than a second teardown. + var stopwatch = Stopwatch.StartNew(); + client.Dispose(); + stopwatch.Stop(); + Assert.IsLessThan(TimeSpan.FromSeconds(2), stopwatch.Elapsed, "Dispose after ShutdownAsync must return immediately."); + Assert.AreEqual(1, server.CompletionCount); + } + + [TestMethod] + public async Task ShutdownAsync_PreservesNonzeroCallbackExitCode() + { + using var server = new InProcessServerFixture(exitCode: 42); + using MtpServerClient client = await LaunchAsync(server); + _ = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)); + + await WithTimeoutAsync(client.ShutdownAsync()); + + Assert.AreEqual(42, client.ServerExitCode, "Shutdown must preserve the exact exit code returned by the connected callback."); + } + + [TestMethod] + public async Task Dispose_FromANotificationHandler_DoesNotSelfWaitOnTheReadLoop() + { + // Teardown runs on the thread pool, so the read-loop AsyncLocal marker the connection uses to detect + // re-entrant disposal has to survive that hop. If it did not, disposing from a handler would stall + // for the connection's read-loop shutdown timeout while the read loop sits in this very handler. + using var server = new InProcessServerFixture(); + + MtpServerClient client = await LaunchAsync(server); + _ = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)); + + var disposeElapsed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.TestNodesUpdated += (_, _) => + { + var stopwatch = Stopwatch.StartNew(); + client.Dispose(); + stopwatch.Stop(); + disposeElapsed.TrySetResult(stopwatch.Elapsed); + }; + + await server.Value.SendDiscoveredTestNodeAsync(Guid.NewGuid(), "uid-1", "Test1"); + + TimeSpan elapsed = await WithTimeoutAsync(disposeElapsed.Task); + Assert.IsLessThan( + TimeSpan.FromSeconds(4), + elapsed, + $"Dispose from a notification handler took {elapsed.TotalMilliseconds:F0} ms; it must not self-wait on the read loop."); + Assert.IsTrue(server.Completion.IsCompleted, "Dispose from the handler must still complete teardown."); + Assert.AreEqual(0, await server.Completion); + Assert.AreEqual(0, client.ServerExitCode, "The callback exit code must still be captured after handler-triggered disposal."); + } + + [TestMethod] + public async Task Dispose_WhileShutdownAsyncIsInFlight_WaitsForTheSameTeardown() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var callbackBlocked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var server = new InProcessServerFixture(onDisconnected: () => + { + callbackBlocked.TrySetResult(true); + release.Task.GetAwaiter().GetResult(); + }); + + MtpServerClient client = await LaunchAsync(server); + _ = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)); + + Task shutdown = client.ShutdownAsync(); + try + { + await WithTimeoutAsync(callbackBlocked.Task); + Assert.IsFalse(shutdown.IsCompleted, "ShutdownAsync must remain incomplete while the callback is blocked."); + + // A Dispose that races an in-flight ShutdownAsync must join it, not report success while the + // application is still stopping. + var disposeEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var dispose = Task.Run( + () => + { + disposeEntered.TrySetResult(true); + client.Dispose(); + }, + TestContext.CancellationToken); + await WithTimeoutAsync(disposeEntered.Task); + + for (int attempt = 0; attempt < 10 && !dispose.IsCompleted; attempt++) + { + _ = await Task.WhenAny(dispose, Task.Delay(10, TestContext.CancellationToken)); + } + + Assert.IsFalse(dispose.IsCompleted, "Dispose must not return while the shared teardown is still running."); + + _ = release.TrySetResult(true); + + await WithTimeoutAsync(shutdown); + await WithTimeoutAsync(dispose); + Assert.AreEqual(1, server.CompletionCount, "Both entry points must share one teardown."); + } + finally + { + _ = release.TrySetResult(true); + } + } + + [TestMethod] + public async Task Dispose_IsIdempotent() + { + using var server = new InProcessServerFixture(); + + MtpServerClient client = await LaunchAsync(server); + _ = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)); + + client.Dispose(); + int afterFirst = server.TransportClosedCount; + var stopwatch = Stopwatch.StartNew(); + client.Dispose(); + client.Dispose(); + stopwatch.Stop(); + + Assert.AreEqual(1, afterFirst, "The first Dispose must close the transport exactly once."); + Assert.AreEqual(1, server.TransportClosedCount, "Repeated Dispose must not tear the application down again."); + Assert.AreEqual(1, server.CompletionCount, "The hosted application must be torn down exactly once."); + Assert.IsLessThan( + TimeSpan.FromSeconds(2), + stopwatch.Elapsed, + "A repeated Dispose joins the already-completed teardown, so it must return immediately."); + } + + [TestMethod] + public async Task Dispose_CallbackFaultsDuringShutdown_DoesNotThrow() + { + using var server = new InProcessServerFixture( + onDisconnected: () => throw new InvalidOperationException("The application failed while shutting down.")); + + MtpServerClient client = await LaunchAsync(server); + _ = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)); + + // A shutdown failure is observed and logged, never rethrown: Dispose runs on the unwind path of the + // caller's own failure and must not replace it. + client.Dispose(); + + Assert.IsTrue(server.Completion.IsFaulted); + InvalidOperationException faultException = Assert.IsInstanceOfType( + server.Completion.Exception?.GetBaseException()); + Assert.AreEqual("The application failed while shutting down.", faultException.Message); + Assert.Contains( + "The in-process MTP application failed", + server.Log, + $"The shutdown failure must be reported through the client logger. Log:{Environment.NewLine}{server.Log}"); + } + + [TestMethod] + public async Task ShutdownAsync_CallbackFaultsDuringShutdown_PropagatesCallbackException() + { + var callbackFailure = new InvalidOperationException("The application failed while shutting down."); + using var server = new InProcessServerFixture(onDisconnected: () => throw callbackFailure); + using MtpServerClient client = await LaunchAsync(server); + _ = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)); + + InvalidOperationException exception = await Assert.ThrowsExactlyAsync( + () => WithTimeoutAsync(client.ShutdownAsync())); + + Assert.AreSame(callbackFailure, exception, "ShutdownAsync must preserve the callback's original post-connect failure."); + } + + [TestMethod] + public async Task ShutdownAsync_CallbackCancelsItselfAfterConnecting_PropagatesCancellation() + { + using var callbackCancellation = new CancellationTokenSource(); + var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using MtpServerClient client = await MtpServerClient.LaunchInProcessAsync( + async (arguments, _) => + { + using FakeMtpServer server = ConnectBack(arguments); + connected.TrySetResult(server); + await Task.Delay(Timeout.Infinite, callbackCancellation.Token); + return 0; + }, + CreateOptions(), + TestContext.CancellationToken); + + _ = await WithTimeoutAsync(connected.Task); + _ = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)); + callbackCancellation.Cancel(); + + TaskCanceledException exception = await Assert.ThrowsExactlyAsync( + () => WithTimeoutAsync(client.ShutdownAsync())); + + Assert.AreEqual(callbackCancellation.Token, exception.CancellationToken); + } + + [TestMethod] + public async Task ShutdownAsync_CallbackHonorsTeardownCancellationThroughLinkedToken_DoesNotThrow() + { + var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + MtpServerClientOptions options = CreateOptions(); + options.ServerShutdownTimeout = TimeSpan.FromMilliseconds(100); + using MtpServerClient client = await MtpServerClient.LaunchInProcessAsync( + async (arguments, serverToken) => + { + using FakeMtpServer server = ConnectBack(arguments); + using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(serverToken); + connected.TrySetResult(server); + await Task.Delay(Timeout.Infinite, linkedCancellation.Token); + return 0; + }, + options, + TestContext.CancellationToken); + + _ = await WithTimeoutAsync(connected.Task); + _ = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)); + + await WithTimeoutAsync(client.ShutdownAsync()); + + Assert.IsNull(client.ServerExitCode, "A callback canceled by teardown did not return an application exit code."); + } + + [TestMethod] + [DoNotParallelize] // Measures overlapping timeout paths; thread-pool contention would measure unrelated tests instead. + public async Task Dispose_BlockedHandlersAndCallback_ReturnsWithinTheDocumentedBound() + { + var neverCompletes = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var handlerEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseHandler = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cancellationHandlerEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseCancellationHandler = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + MtpServerClientOptions options = CreateOptions(); + options.ServerShutdownTimeout = TimeSpan.FromSeconds(1); + var log = new StringBuilder(); + options.Logger = new DelegateMtpClientLogger((_, message) => + { + lock (log) + { + log.AppendLine(message); + } + }); + + try + { + using MtpServerClient client = await MtpServerClient.LaunchInProcessAsync( + async (arguments, serverToken) => + { + using FakeMtpServer server = ConnectBack(arguments); + connected.TrySetResult(server); + using CancellationTokenRegistration registration = serverToken.Register(() => + { + cancellationHandlerEntered.TrySetResult(true); + releaseCancellationHandler.Task.GetAwaiter().GetResult(); + }); + + // Deliberately ignores both the closed transport and the cancellation token. + await neverCompletes.Task; + return 0; + }, + options, + TestContext.CancellationToken); + + _ = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)); + + client.TestNodesUpdated += (_, _) => + { + handlerEntered.TrySetResult(true); + releaseHandler.Task.GetAwaiter().GetResult(); + }; + + FakeMtpServer connectedServer = await WithTimeoutAsync(connected.Task); + await connectedServer.SendDiscoveredTestNodeAsync(Guid.NewGuid(), "uid-1", "Test1"); + await WithTimeoutAsync(handlerEntered.Task); + + var stopwatch = Stopwatch.StartNew(); + client.Dispose(); + stopwatch.Stop(); + + // The connection's fixed 5s read-loop wait must overlap ServerShutdownTimeout (1s) plus the fixed + // 5s cancellation grace. Running those waits serially would take at least 11s. + Assert.IsLessThan( + TimeSpan.FromSeconds(9), + stopwatch.Elapsed, + $"Dispose took {stopwatch.Elapsed.TotalSeconds:N1}s; it must abandon an unresponsive application within the documented bound rather than block indefinitely."); + + Assert.IsTrue( + await WithTimeoutAsync(cancellationHandlerEntered.Task), + "Shutdown must request cancellation without waiting inline for a blocking callback registration."); + + string text; + lock (log) + { + text = log.ToString(); + } + + Assert.Contains("abandoning it", text, $"Abandoning the application must be reported. Log:{Environment.NewLine}{text}"); + } + finally + { + _ = releaseHandler.TrySetResult(true); + _ = releaseCancellationHandler.TrySetResult(true); + _ = neverCompletes.TrySetResult(true); + } + } + + [TestMethod] + public async Task RunTestsAsync_Canceled_SendsCancelRequestToTheHostedApplication() + { + using var server = new InProcessServerFixture(); + + using MtpServerClient client = await LaunchAsync(server); + server.Value.WithholdRunResponse = true; + _ = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)); + + using var cancellation = new CancellationTokenSource(); + Task run = client.RunTestsAsync(cancellation.Token); + _ = await WithTimeoutAsync(server.Value.WaitForRequestAsync(JsonRpcMethods.TestingRunTests, DefaultTimeout)); + + cancellation.Cancel(); + + await Assert.ThrowsExactlyAsync(() => WithTimeoutAsync(run)); + await WithTimeoutAsync(server.Value.WaitForNotificationAsync(JsonRpcMethods.CancelRequest, DefaultTimeout)); + } + + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task LaunchInProcessAsync_HonorsTheStatefulOption(bool isStateful) + { + using var server = new InProcessServerFixture(); + MtpServerClientOptions options = CreateOptions(); + options.IsStateful = isStateful; + options.ClientName = "EmbeddedHost"; + + using MtpServerClient client = await LaunchAsync(server, options); + MtpServerCapabilities capabilities = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)); + + RequestMessage initialize = server.Value.ReceivedRequests.Single(request => request.Method == JsonRpcMethods.Initialize); + InitializeRequestArgs args = initialize.Params is InitializeRequestArgs typed + ? typed + : SerializerUtilities.Deserialize((IDictionary)initialize.Params!); + + Assert.AreEqual(isStateful, args.Capabilities.IsStateful, "The in-process path must forward the client's stateful capability unchanged."); + Assert.AreSame(capabilities, client.Capabilities, "The client must retain the capabilities negotiated with the in-process server."); + Assert.AreEqual("EmbeddedHost", args.ClientInfo.Name); + Assert.AreEqual(CurrentProcessId, args.ProcessId); + } + + [TestMethod] + public async Task LaunchInProcessAsync_MultipleRequestsOnOneSession_ReuseTheSameConnection() + { + using var server = new InProcessServerFixture(); + MtpServerClientOptions options = CreateOptions(); + options.IsStateful = true; + + using MtpServerClient client = await LaunchAsync(server, options); + MtpServerCapabilities capabilities = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)); + Assert.IsTrue(capabilities.MultiRequestSupport, "The fake server negotiates keep-alive back."); + + await WithTimeoutAsync(client.DiscoverTestsAsync(TestContext.CancellationToken)); + _ = await WithTimeoutAsync(client.RunTestsAsync(TestContext.CancellationToken)); + _ = await WithTimeoutAsync(client.RunTestsWithFilterAsync("/*/*/*/*", TestContext.CancellationToken)); + + Assert.AreEqual( + 1, + server.ConnectionCount, + "A stateful session must serve every request over the single connection the launch established."); + Assert.AreSequenceEqual( + new[] + { + JsonRpcMethods.Initialize, + JsonRpcMethods.TestingDiscoverTests, + JsonRpcMethods.TestingRunTests, + JsonRpcMethods.TestingRunTests, + }, + server.Value.ReceivedRequestMethods, + "The stateful session must reuse one connection for the expected initialize, discover, and run requests."); + } + + [TestMethod] + public async Task LaunchInProcessAsync_IgnoresEnvironmentVariablesAndWarns() + { + using var server = new InProcessServerFixture(); + MtpServerClientOptions options = CreateOptions(); + options.EnvironmentVariables["EXAMPLE"] = "1"; + + using MtpServerClient client = await LaunchAsync(server, options); + + Assert.Contains( + nameof(MtpServerClientOptions.EnvironmentVariables), + server.Log, + "An in-process application shares the caller's environment, so silently dropping the variables would be a trap."); + } + + private async Task LaunchAsync(InProcessServerFixture server, MtpServerClientOptions? options = null) + { + options ??= CreateOptions(); + + // Route the client's own diagnostics into the fixture so a test can assert on them. + options.Logger ??= new DelegateMtpClientLogger((_, message) => server.Append(message)); + + MtpServerClient client = await WithTimeoutAsync(MtpServerClient.LaunchInProcessAsync( + server.RunAsync, + options, + TestContext.CancellationToken)); + + try + { + // The client's accept can complete while the callback is still inside its own connect call, so + // wait for the callback to publish its server before any test touches it. + _ = await WithTimeoutAsync(server.Connected); + } + catch + { + client.Dispose(); + throw; + } + + return client; + } + + private static MtpServerClientOptions CreateOptions() + => new() + { + // Keep every negative test bounded well below the suite timeout. + ConnectionTimeout = TimeSpan.FromSeconds(20), + ServerShutdownTimeout = TimeSpan.FromSeconds(10), + }; + + private static FakeMtpServer ConnectBack(string[] serverArguments) + { + string host = ReadArgument(serverArguments, "--client-host"); + int port = int.Parse(ReadArgument(serverArguments, "--client-port"), CultureInfo.InvariantCulture); + return FakeMtpServer.ConnectBackTo(host, port); + } + + private static string ReadArgument(string[] serverArguments, string name) + { + int index = Array.IndexOf(serverArguments, name); + return index >= 0 && index + 1 < serverArguments.Length + ? serverArguments[index + 1] + : throw new InvalidOperationException($"The client did not pass '{name}'. Arguments: {string.Join(" ", serverArguments)}"); + } + + private static int GetCurrentProcessId() + { + using var current = Process.GetCurrentProcess(); + return current.Id; + } + + private static async Task WithTimeoutAsync(Task task) + { + Task completed = await Task.WhenAny(task, Task.Delay(DefaultTimeout)).ConfigureAwait(false); + return completed != task + ? throw new TimeoutException("Timed out waiting for the operation to complete.") + : await task.ConfigureAwait(false); + } + + private static async Task WithTimeoutAsync(Task task) + { + Task completed = await Task.WhenAny(task, Task.Delay(DefaultTimeout)).ConfigureAwait(false); + if (completed != task) + { + throw new TimeoutException("Timed out waiting for the operation to complete."); + } + + await task.ConfigureAwait(false); + } + + private static async Task AssertThrowsAsync(Func action) + where TException : Exception + => await Assert.ThrowsExactlyAsync(() => WithTimeoutAsync(action())); + + /// + /// Plays the part of the hosted MTP application: it reads the client-generated arguments, dials back to + /// the client's listener, serves the protocol until the client closes the connection, and then completes + /// like a real TestApplication.RunAsync would. + /// + private sealed class InProcessServerFixture : IDisposable + { + private readonly Action? _onDisconnected; + private readonly int _exitCode; + private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _connected = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly StringBuilder _log = new(); + + private FakeMtpServer? _server; + private int _connectionCount; + private int _completionCount; + private int _transportClosedCount; + + public InProcessServerFixture(Action? onDisconnected = null, int exitCode = 0) + { + _onDisconnected = onDisconnected; + _exitCode = exitCode; + } + + /// Gets the argument array the client generated for the callback (empty before it ran). + public string[] Arguments { get; private set; } = []; + + /// Gets the served fake server. Valid once the launch has completed. + public FakeMtpServer Value => _server ?? throw new InvalidOperationException("The callback has not connected yet."); + + /// + /// Completes once the callback has published its server. The client's accept can complete while the + /// callback is still inside its own connect call, so a test must await this before touching + /// rather than assuming the launch returning means the callback finished setting + /// itself up. + /// + public Task Connected => _connected.Task; + + /// Gets the task that mirrors the hosted application's lifetime. + public Task Completion => _completion.Task; + + /// Gets how many times the callback dialed back to the client. + public int ConnectionCount => Volatile.Read(ref _connectionCount); + + /// Gets how many times the hosted application ran to completion. + public int CompletionCount => Volatile.Read(ref _completionCount); + + /// + /// Gets how many times the client closed the transport. Teardown is observable from the server side, + /// so a repeated Dispose that skipped its guard would show up here as a second close. + /// + public int TransportClosedCount => Volatile.Read(ref _transportClosedCount); + + /// Gets the diagnostics the client emitted through its logger. + public string Log + { + get + { + lock (_log) + { + return _log.ToString(); + } + } + } + + public void Append(string message) + { + lock (_log) + { + _log.AppendLine(message); + } + } + + public async Task RunAsync(string[] serverArguments, CancellationToken cancellationToken) + { + Arguments = serverArguments; + FakeMtpServer server = ConnectBack(serverArguments); + _server = server; + _connected.TrySetResult(server); + _ = Interlocked.Increment(ref _connectionCount); + + try + { + // A real server-mode application runs until its session ends, which is what closing the + // client's end of the transport produces. + await server.Disconnected.ConfigureAwait(false); + _ = Interlocked.Increment(ref _transportClosedCount); + _onDisconnected?.Invoke(); + _ = Interlocked.Increment(ref _completionCount); + _ = _completion.TrySetResult(_exitCode); + return _exitCode; + } + catch (Exception exception) + { + _ = Interlocked.Increment(ref _completionCount); + _ = _completion.TrySetException(exception); + throw; + } + } + + public void Dispose() + { + _server?.Dispose(); + _ = _completion.TrySetResult(0); + _ = _connected.TrySetCanceled(); + } + } +} diff --git a/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/MtpServerClientTests.cs b/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/MtpServerClientTests.cs index 257cfa89af..ca746e0227 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/MtpServerClientTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/MtpServerClientTests.cs @@ -696,6 +696,44 @@ public async Task Dispose_CalledFromNotificationHandler_DoesNotSelfWaitOnTheRead $"Dispose from a notification handler took {elapsed.TotalMilliseconds:F0} ms; it must not self-wait on the read loop."); } + [TestMethod] + public async Task ShutdownAsync_WithBlockedNotificationHandler_ReturnsWithoutBlockingTheCaller() + { + using FakeMtpServer server = new(); + MtpServerClient client = await ConnectAndInitializeAsync(server).ConfigureAwait(false); + var handlerEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseHandler = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + client.TestNodesUpdated += (_, _) => + { + handlerEntered.TrySetResult(true); + releaseHandler.Task.GetAwaiter().GetResult(); + }; + + try + { + await server.SendDiscoveredTestNodeAsync(Guid.NewGuid(), "Ns.Class.Test", "Test").ConfigureAwait(false); + await WithTimeoutAsync(handlerEntered.Task).ConfigureAwait(false); + + var stopwatch = Stopwatch.StartNew(); + Task shutdown = client.ShutdownAsync(); + stopwatch.Stop(); + + Assert.IsLessThan( + TimeSpan.FromSeconds(2), + stopwatch.Elapsed, + $"Calling ShutdownAsync took {stopwatch.Elapsed.TotalMilliseconds:F0} ms; connection teardown must be scheduled rather than blocking the caller."); + + _ = releaseHandler.TrySetResult(true); + await WithTimeoutAsync(shutdown).ConfigureAwait(false); + } + finally + { + _ = releaseHandler.TrySetResult(true); + client.Dispose(); + } + } + private static async Task ConnectAndInitializeAsync(FakeMtpServer server) { MtpServerClient client = server.ConnectClient();