Skip to content

Add in-process launch support to the MTP server-mode client - #10898

Open
Amaury Levé (Evangelink) wants to merge 4 commits into
mainfrom
dev/amauryleve/mtp-in-process-client-launch
Open

Add in-process launch support to the MTP server-mode client#10898
Amaury Levé (Evangelink) wants to merge 4 commits into
mainfrom
dev/amauryleve/mtp-in-process-client-launch

Conversation

@Evangelink

Copy link
Copy Markdown
Member

Fixes#10890

Why

MtpServerClient.LaunchAsync(path) owns the loopback listener and starts the test application with Process.Start. That is right for IDE and desktop tooling, but unusable for embedded hosts — MAUI apps, Android/iOS test apps — where the MTP application already runs in the caller's process.

Today those hosts have to reimplement the listener, the --server/host/port arguments, the race between connection and startup failure, the serializer-before-formatter registration order, TcpMessageHandler + MtpJsonRpcConnection construction, and the exit / transport-close / server-completion shutdown dance. That defeats the point of shipping a canonical client and makes it easy to reintroduce bugs this package already solved: partial frame writes, ignored JSON-RPC errors, missing $/cancelRequest, unbounded waits, exception masking.

A concrete consumer is the DeviceRunners MSTest visual-runner work (mattleibow/DeviceRunners#157, #9809).

What

usingIMtpServerClientclient=awaitMtpServerClient.LaunchInProcessAsync(async(serverArgs,token)=>{ITestApplicationBuilderbuilder=awaitTestApplication.CreateBuilderAsync(serverArgs);builder.AddMSTest(()=>testAssemblies);usingITestApplicationapp=awaitbuilder.BuildAsync();returnawaitapp.RunAsync();},options,cancellationToken);awaitclient.InitializeAsync();awaitclient.DiscoverTestsAsync();awaitclient.RunTestsAsync();awaitclient.ExitAsync();awaitclient.ShutdownAsync();

The caller supplies only "how to run the application". The client keeps everything else: it binds the listener, generates the complete argument array (--server jsonrpc --client-host 127.0.0.1 --client-port <port> --no-banner), races the connect, builds the transport, and owns a bounded shutdown.

New API on the injected (still internal) surface:

MemberPurpose
MtpServerClient.LaunchInProcessAsync(callback, options, ct)The embedded-host launch path. Async only — blocking the launching thread can deadlock the application being launched.
IMtpServerClient.ShutdownAsync()Non-blocking teardown.
IMtpServerClient.ServerExitCodeThe value the application returned.
MtpServerClientOptions.ServerShutdownTimeoutGraceful shutdown bound (default 30s).

Internally, the transport setup was extracted from MtpServerProcess into MtpServerConnector, and both launch paths now sit behind IMtpServerHost, so they cannot drift.

Behavior worth reviewing

Ownership and shutdown. Both Dispose() and ShutdownAsync() join one lazily created shared teardown task, on both hosts. A Dispose that follows or races ShutdownAsync therefore returns only once the server has actually stopped, rather than reporting success while teardown is still running. Teardown runs on the thread pool, so ShutdownAsync never blocks and the synchronous Dispose cannot deadlock against a UI-thread continuation. Task.Run captures the execution context, so the connection's read-loop AsyncLocal marker still flows and disposing from a notification handler does not self-wait (covered by a test asserting < 4s against the connection's 5s read-loop timeout).

Bounded, and actually bounded. Teardown waits ServerShutdownTimeout, then cancels the callback's token, then a fixed 5s grace, then abandons and logs. CancellationTokenSource.Cancel() runs registrations synchronously, so the cancellation is started separately — otherwise a blocking caller registration would prevent the grace from ever starting and make the "bounded" wait unbounded. A failed launch skips the graceful wait entirely: nothing is connected, so there is no transport closure for the callback to observe.

Exception preservation. A callback that throws, is canceled, or returns before dialing back surfaces as MtpServerConnectionClosedException with the original as InnerException, instead of a misleading connection timeout. Every teardown helper is non-throwing, and the shared teardown task is wrapped so it can never fault — a faulted shared task would throw from every later disposal.

LaunchAsync(path) is unchanged. Verified against main: identical argument string, failure messages, stderr capture, exit fast-fail and teardown order. One earlier revision of this branch added a grace period to the accept race; review showed it only traded a precise failure for a vague one, so it was removed and the external path is now provably unchanged.

Browser/WASM. Both paths are loopback TCP. LaunchInProcessAsync fails fast with PlatformNotSupportedException there; this does not enable WASM hosting, and the docs say so.

Tests

  • 68 unit tests (net8.0) / 63 (net462, Jsonite path). New coverage: argument array shape; initialize → discover → run → exit; callback faulting synchronously and asynchronously; callback exiting with a code; null task; pre-canceled launch not invoking the callback; cancellation during connect canceling the callback token; connection timeout bounded by the grace rather than ServerShutdownTimeout; ShutdownAsync; ServerExitCode; disposal awaiting the callback; Dispose racing an in-flight ShutdownAsync; Dispose from a notification handler; idempotent disposal; callback faulting during shutdown; unresponsive callback abandoned within the bound; $/cancelRequest; stateful on/off; multi-request single connection; EnvironmentVariables ignored and warned. Stressed 6×/5× consecutively for flakiness.
  • New acceptance test where a single generated process is simultaneously the embedded host (compiling the packed source-only package) and a real MTP TestApplication with real MSTest over a real [TestClass] — discovering and running its own test over JSON-RPC with no Process.Start anywhere. It also asserts the server's reported process id equals its own.
  • Existing external-process acceptance test extended to cover ShutdownAsync + ServerExitCode; hostile-consumer compile oracle extended to bind the new API on net462/netstandard2.0/net5.0–net8.0.
  • Full regression: 13 platform source-package/consumer acceptance, 4 MSTest acceptance, 13 platform ServerTests. build.cmd -pack clean, 0 warnings.

Review history

Four independent review rounds (MTP/MSTest expert reviewer, a design reviewer, and two correctness reviewers) produced 3 major, 10 moderate and 10 minor findings, all addressed. Several were real bugs the tests then locked in — notably Process.ExitCode being unreadable after Process.Dispose(), a TcpListener socket leak when Start() failed, and a teardown path that could throw from a contract documented never to throw.

Open design questions

  1. No IAsyncDisposable. netstandard2.0/net462 would need Microsoft.Bcl.AsyncInterfaces, breaking the package's dependency-free promise. ShutdownAsync() is the substitute. Happy to revisit if the dependency is acceptable.
  2. MtpServerClientOptions is mode-mixed.EnvironmentVariables is external-process only; ServerShutdownTimeout is in-process only. Nesting per-transport options would age better, but EnvironmentVariables already shipped at the top level, so it cannot be done non-breakingly now.
  3. The 5s cancellation grace is a fixed constant, not an option.
  4. The two paths still build different argument shapes — deliberate, to keep the shipped external command line byte-identical. Commented at the call site.
  5. No ConnectAsync(TcpClient/Stream, options) factory — the existing MtpServerClient(MtpJsonRpcConnection, options) constructor already covers "wrap an existing transport", and the guidance is to minimize injected surface. Easy to add if wanted.

Embedded hosts such as MAUI or Android/iOS test apps cannot spawn a child
process, so `MtpServerClient.LaunchAsync(path)` was unusable for them and they
had to reimplement the listener, the server-mode arguments, the connect race,
the serializer/formatter ordering, the transport and the shutdown coordination
by hand -- reintroducing the bugs the canonical client already solves.
Add `MtpServerClient.LaunchInProcessAsync(callback, options, cancellationToken)`.
The client keeps ownership of everything except "how to run the application":
it binds the loopback listener, generates the complete server-mode argument
array, races the connect against callback failure/completion, caller
cancellation and the connection timeout, and owns a bounded shutdown that
closes the transport, then cancels the callback token, then abandons it rather
than hanging the caller. A callback failure before connection is surfaced as
`MtpServerConnectionClosedException` with the caller's exception preserved as
the inner exception, and teardown failures are only logged so they can never
replace the primary failure.
The shared transport setup is extracted from `MtpServerProcess` into
`MtpServerConnector` and both launch paths now flow through `IMtpServerHost`,
so the external-process behavior is unchanged while the two paths cannot drift.
The one behavior change is an improvement shared by both: when the server is
seen to have stopped, a still-pending accept gets a bounded final grace so a
connection established just before the stop is not discarded in favor of a
misleading "stopped before connecting back" failure.
The path is loopback TCP, so it fails fast with `PlatformNotSupportedException`
on browser/WASM; it does not enable WASM hosting.
Fixes#10890
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two independent reviews of the in-process launch path raised the same top
finding: `Dispose()` waits for the hosted application synchronously on the
calling thread, and on the very platforms this feature targets (MAUI, Android,
iOS) a multi-second block on the UI thread trips the ANR / watchdog. Add
`IMtpServerClient.ShutdownAsync()` (and `IMtpServerHost.ShutdownAsync`) so the
same teardown can be awaited instead. `IAsyncDisposable` stays rejected --
netstandard2.0 and net462 would need `Microsoft.Bcl.AsyncInterfaces`, which
breaks the package's dependency-free promise -- but rejecting the interface is
not a reason to have no asynchronous shutdown at all. `Dispose()` remains, is
still idempotent, and returns immediately after `ShutdownAsync`.
Remove the accept grace period. The previous commit let a still-pending accept
win for ~100ms after the server was seen to have stopped, on the theory that a
connection established just before the stop should not be discarded. That
theory does not hold: if the server has stopped, the socket belongs to a dead
peer, so the grace only traded a precise failure (exit code plus captured
stderr) for a generic connection-closed error on the first request, and its
uncancellable delay could let the server failure beat a concurrent caller
cancellation. Dropping it also makes the external-process path provably
unchanged, so the changelog no longer needs a `Changed` entry.
Other review fixes:
* Expose `IMtpServerClient.ServerExitCode`. The callback signature already
demanded a `Task<int>`, but after a successful session the value was
unreachable, so an embedded host whose `Main` must return it had to capture
it in a closure.
* Do not dispose the server's `CancellationTokenSource` when the callback was
abandoned while still running: it holds the token, and `token.WaitHandle` or
`CreateLinkedTokenSource` would then throw `ObjectDisposedException` inside
the caller's own code.
* Skip the graceful wait entirely on a failed launch. Nothing is connected, so
there is no transport closure for the callback to observe; only the fixed
cancellation grace applies and an unwinding caller no longer pays
`ServerShutdownTimeout`.
* Clamp bounded waits instead of trusting the caller's `TimeSpan`. A negative
value (or `Timeout.InfiniteTimeSpan`) made `Task.Delay` throw from a path
documented never to throw; an oversized one is capped to the largest delay
.NET Framework accepts.
* Move `TcpListener.Start()` inside the cleanup `try`: it creates the socket
before binding, so a bind failure leaked it because the caller never received
a listener it could stop.
* Log a late failure from an abandoned callback rather than only observing it.
* Cache the in-process host's `ProcessId` instead of allocating a `Process` per
property read.
* Document that `Dispose()` blocks, that cancellation is bounded rather than
immediate, and why the two launch paths still build different argument
shapes; fix the shadowed `cancellationToken` in the PACKAGE.md sample.
Tests: cover `ShutdownAsync`, `ServerExitCode`, and fail-fast on a server that
stops without connecting; make `Dispose_IsIdempotent` able to fail by asserting
on observed transport closes rather than callback invocations; replace the
hand-rolled throws helper with `Assert.ThrowsExactlyAsync`; tighten the
shutdown-bound assertion; and stop leaking `Process` handles.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A third review round found three ways the teardown contract did not hold up
under concurrency.
`Dispose()` and `ShutdownAsync()` each had their own `Interlocked.Exchange`
guard, so whichever call lost the race returned while teardown was still
running -- reporting to its caller that the application had stopped when it had
not. Replace both guards with one lazily started, shared teardown task: every
entry point now joins the same task, so `Dispose` after (or racing)
`ShutdownAsync` returns only once the application has actually stopped, and the
whole thing stays idempotent. `MtpServerClient` no longer keeps its own guard
either; it delegates to the host, whose teardown is the single source of truth.
`ShutdownAsync()` could still block the very thread it exists to protect: it
closed the transport before its first await, and closing the connection waits
up to five seconds for the read loop. The shared task is started with
`Task.Run`, so the whole teardown -- transport close included -- runs on the
thread pool and `ShutdownAsync` returns immediately. `MtpServerProcess`
likewise now runs its (bounded but synchronous) kill off the calling thread
rather than pretending to be async while blocking.
`CancellationTokenSource.Cancel()` executes registrations synchronously on the
calling thread, so a caller registration that blocked would prevent the
five-second cancellation grace from ever starting and make the "bounded"
shutdown unbounded. Start the cancellation separately and begin the grace
regardless. When a registration is still executing once the callback has
finished, the source is reported as unsafe to dispose for the same reason an
abandoned callback is: leaking one `CancellationTokenSource` beats a
use-after-dispose inside the caller's code.
Adds a test that a `Dispose` racing an in-flight `ShutdownAsync` blocks until
the shared teardown completes, and that both share one teardown.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… fix ExitCode
A fourth review round found that only the in-process host had been converted to
the shared-teardown design. `MtpServerProcess` kept its old early-return guard,
so on the external-process path a `Dispose()` that followed or raced
`ShutdownAsync()` returned while `SafeKill`'s bounded `WaitForExit` was still
running on another thread -- losing exactly the guarantee that wait exists for
(a caller may delete the application directory immediately after disposal), and
making `await ShutdownAsync()` report that the server had stopped when it had
not. That contradicted the comment the previous commit added to
`MtpServerClient.Dispose`, which claimed both paths joined an in-flight
teardown. Convert `MtpServerProcess` to the same lazily created shared task, so
the contract is now uniform across both implementations of `IMtpServerHost`,
and reconcile the interface docs, which previously described the opposite rule.
Adding real coverage for `ShutdownAsync` on the external-process path then
surfaced a genuine bug: `MtpServerProcess.ExitCode` was always `null` after
teardown, because a `Process` cannot be queried once disposed. Capture the exit
code during teardown instead -- before the kill, so an application that already
exited on its own reports its real code rather than the kill's, and before
`Process.Dispose()`, after which nothing is readable.
Also fixes a race in the in-process test fixture that made the suite flaky on
net462: the callback published its `FakeMtpServer` only after its own connect
call returned, but the client's accept can complete first, so a test could
reach `Value` before the callback had set it. The fixture now exposes a
`Connected` signal the launch helper awaits. 6/6 clean net462 runs and 5/5
clean net8.0 runs afterwards.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI balanced review requested due to automatic review settings August 31, 2026 19:22
Comment on lines +182 to +193
if (serverTask is not null)
{
// 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.
if (!await ShutdownServerAsync(serverTask, serverCancellation!, TimeSpan.Zero, logger).ConfigureAwait(false))
{
// 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;
}
}
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Expert test review — PR #10898

GradeTestMutationNotesHow to improve
B (80–89)new MtpServerClientInProcessTests.
Dispose_CallbackFaultsDuringShutdown_
DoesNotThrow
2/3 killedReads server.Completion.Exception but never asserts on it, so a mutation changing the faulted exception's identity/message survives.Assert the captured exception's message/type instead of discarding it after touching .Exception.
B (80–89)new MtpServerClientInProcessTests.
LaunchInProcessAsync_HonorsTheStatefulOption
3/4 killedOnly asserts the sent initialize args; a mutation dropping the negotiated round-trip value on client.Capabilities is not caught.Also assert client.Capabilities.IsStateful reflects the negotiated round-trip value, not only the sent request.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_PassesCompleteServerModeArguments
5/5 killedVerifies exact ordered arguments, dynamic port and fixed count; strong regression guard for the generated argument array.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_DrivesInitializeDiscoverRunAndExit
6/6 killedEnd-to-end drive through initialize/discover/run/exit with distinct assertions per state transition.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackFaultsBeforeConnecting_
PreservesCallbackException
2/2 killedAsserts the exact exception instance is preserved as inner exception, not just its type.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackFaultsAsynchronouslyBeforeConnecting_
PreservesCallbackException
2/2 killedCovers the async-throw variant with the same identity assertion as the sync case.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackExitsWithoutConnecting_
FailsFastInsteadOfWaitingOutTheTimeout
3/3 killedChecks both the reported exit code in the message and a timing bound guarding against a slow-timeout regression.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackExitsBeforeConnecting_
ReportsExitCode
2/2 killedAsserts the specific exit code surfaces in the exception message.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackReturnsNullTask_Fails
2/2 killedAsserts the specific inner exception type for a misbehaving callback returning a null task.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_NullCallback_Throws
1/1 killedFocused single-assertion guard-clause test using the exact exception type.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_AlreadyCanceled_
DoesNotInvokeCallback
2/2 killedVerifies both the cancellation exception and, via an interlocked counter, that the callback never ran.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CanceledWhileConnecting_
CancelsTheCallbackToken
2/2 killedConfirms both the caller-side cancellation and that the callback's own token observed cancellation.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_ConnectionTimeoutElapses_
FailsWithTimeoutMessage
3/3 killedDistinguishes the connection timeout from the deliberately huge shutdown timeout via message and elapsed-time bound.
A (90–100)new MtpServerClientInProcessTests.
Dispose_ClosesTransportAndAwaitsTheCallback
4/4 killedChecks pre/post completion state, the callback's returned exit code, and the client's reported exit code.
A (90–100)new MtpServerClientInProcessTests.
ShutdownAsync_ClosesTransportAndAwaitsTheCallback_
WithoutBlocking
4/4 killedVerifies await-completion, exit code, a fast follow-up Dispose, and single-teardown count together.
A (90–100)new MtpServerClientInProcessTests.
Dispose_FromANotificationHandler_
DoesNotSelfWaitOnTheReadLoop
1/1 killedTargeted timing assertion guards against a specific re-entrancy deadlock regression.
A (90–100)new MtpServerClientInProcessTests.
Dispose_WhileShutdownAsyncIsInFlight_
WaitsForTheSameTeardown
3/3 killedUses a controlled release gate to prove the racing Dispose genuinely blocks, then confirms a single shared teardown.
A (90–100)new MtpServerClientInProcessTests.
Dispose_IsIdempotent
3/3 killedConfirms exactly one transport close and one completion despite three Dispose calls, plus a fast-return bound.
A (90–100)new MtpServerClientInProcessTests.
Dispose_CallbackIgnoresShutdown_
ReturnsWithinTheDocumentedBound
2/2 killedVerifies both the documented abandonment time bound and the logged "abandoning" diagnostic.
A (90–100)new MtpServerClientInProcessTests.
RunTestsAsync_Canceled_
SendsCancelRequestToTheHostedApplication
2/2 killedConfirms both the client-side cancellation exception and the wire-level cancel notification reaching the fake server.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_MultipleRequestsOnOneSession_
ReuseTheSameConnection
3/3 killedChecks keep-alive negotiation, single-connection reuse count, and total request count together.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_IgnoresEnvironmentVariablesAndWarns
1/1 killedAsserts the specific warning content naming the ignored option.
A (90–100)new MtpServerClientInProcessAcceptanceTests.
InProcessHost_
DiscoversAndRunsItsOwnMSTestNodes_
WithoutStartingAProcess
5/5 killedReal end-to-end embedded-host run asserting build success, exit code, and each marker line the generated app emits.
A (90–100)mod MtpServerClientAcceptanceTests.
DiscoverAndRun_ViaSourcePackageClient_
ReportsExpectedTestNode
1/1 killedNew lines assert the external-process ShutdownAsync/Dispose share a teardown and report an exit code.
A (90–100)mod MtpServerClientSourcePackageConsumerTests.
HostileConsumer_
CompilesAgainstPackedSource
N/ACompile-only guard; added lines correctly extend surface coverage to ShutdownAsync and the in-process launch path.

This advisory comment was generated automatically. Grades are heuristic
and informational — they do not block merging. Suggestions on the Files
changed tab can be applied with one click. Re-run with
/review-tests.

🤖 Automated content by GitHub Copilot. Generated by the Test Reviewer on PR (on open / sync) workflow. · auto · 159.6 AIC · ⌖ 2.87 AIC · ⊞ 16.9K · [◷]( · )

@github-actions

Copy link
Copy Markdown
Contributor

🧵 Parallel-safety audit — PR #10898

Parallelization — audited assemblies:

Test assemblyScopeWorkersAnalyzer coverage
MSTest.Acceptance.IntegrationTestsMethodLevel (default [assembly: Parallelize(Scope = ExecutionScope.MethodLevel, Workers = 0)] in Program.cs)CPU countcoverable once the parallel-safety analyzers ship (attribute-based opt-in)
Microsoft.Testing.Platform.Acceptance.IntegrationTestsMethodLevel (Program.cs)CPU countcoverable once shipped
Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTestsMethodLevel (Program.cs)CPU countcoverable once shipped

This PR did not touch any .runsettings/testconfig.json/Directory.Build.* parallelization config — no assembly's scope changed.

Findings: A (global-state) 0 · B (paths) 0 · C (declaration) 0 · D (over-serialization) 0 — by severity: Critical 0 · High 0 · Warning 0 · Info 0.

No unsafe call sites found in the lines this PR added or modified.

  • MtpServerClientAcceptanceTests.cs — added lines only call client.ShutdownAsync() / assert ServerExitCode; no process-global state touched.
  • MtpServerClientSourcePackageConsumerTests.cs — added lines call into an isolated generated test asset (DriveAsync/DriveInProcessAsync); no shared/relative paths, env vars, culture, or console state mutated at the assembly level (Console.WriteLine here writes to the generated app's own redirected output, not the auditing process).
  • MtpServerClientInProcessAcceptanceTests.cs (new) — uses TestAsset.GenerateAssetAsync, which allocates a unique GUID-suffixed TempDirectory per instance (TestAsset.cs:19-26, throws if a path already exists) — no shared-path collision. CreateChildEnvironment() builds a fresh Dictionary passed only to the spawned child process's environment, not Environment.SetEnvironmentVariable, so it does not mutate this process's state.
  • FakeMtpServer.cs / MtpServerClientInProcessTests.cs (new) — each test constructs its own TcpListener/FakeMtpServer bound to an OS-assigned loopback port (new TcpListener(IPAddress.Loopback, 0)), so there is no fixed-port collision between concurrently-running tests; no static/process-global state is written.

Nothing here implies a testability or smell concern beyond this audit's scope.

Advisory only — heuristic, non-blocking. Re-run with /parallel-audit. This audit answers "is it parallel-safe?"; for testability, smells, or flakiness see the detect-static-dependencies / test-smell-detection / test-anti-patterns analyses.

🤖 Automated content by GitHub Copilot. Generated by the Parallel-safety audit on PR (on open / sync) workflow. · auto · 117.1 AIC · ⌖ 3.53 AIC · ⊞ 24.8K · [◷]( · )

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated content by GitHub Copilot. Generated by the Test Reviewer on PR (on open / sync) workflow. · auto · 159.6 AIC · ⌖ 2.87 AIC · ⊞ 16.9K ·

client.Dispose();

Assert.IsTrue(server.Completion.IsFaulted);
_ = server.Completion.Exception;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test review · Grade B (80–89) — Reads server.Completion.Exception but never asserts on it, so a mutation changing the faulted exception's identity/message would survive.

Assert the captured exception's message/type instead of discarding it after touching .Exception.

Suggested change
_=server.Completion.Exception;
Exception?faultException=server.Completion.Exception?.GetBaseException();
Assert.IsInstanceOfType<InvalidOperationException>(faultException);
Assert.AreEqual("The application failed while shutting down.",faultException!.Message);

? typed
: SerializerUtilities.Deserialize<InitializeRequestArgs>((IDictionary<string, object?>)initialize.Params!);

Assert.AreEqual(isStateful, args.Capabilities.IsStateful, "The in-process path must forward the client's stateful capability unchanged.");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test review · Grade B (80–89) — Only asserts the sent initialize args; a mutation dropping the negotiated round-trip value on client.Capabilities is not caught.

Also assert client.Capabilities.IsStateful reflects the negotiated round-trip value, not only the sent request.

Suggested change
Assert.AreEqual(isStateful,args.Capabilities.IsStateful,"The in-process path must forward the client's stateful capability unchanged.");
Assert.AreEqual(isStateful,args.Capabilities.IsStateful,"The in-process path must forward the client's stateful capability unchanged.");
Assert.AreEqual(isStateful,capabilities.IsStateful,"The negotiated capability the client stores must reflect the server's round trip, not just the sent request.");

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

🤖 Automated review by GitHub Copilot. Generated by the Expert Code Review workflow. To request a follow-up action, reply by tagging @copilot directly.

✅ 22/22 dimensions clean — no findings.

Summary: This PR adds MtpServerClient.LaunchInProcessAsync for embedded hosts (MAUI, Android/iOS) that cannot Process.Start. The implementation is thorough and well-structured:

  • Threading & Concurrency: Shutdown is idempotent via lock + lazy Task, Volatile.Read/Write for cross-thread exit code, Task.Run avoids sync-context deadlocks. No shared mutable state without synchronization.
  • Security & IPC: Loopback TCP only, no command-line injection surface (arguments are array-based), WASM fails fast with PlatformNotSupportedException.
  • Public API: All new API is internal (source-only package). No init accessors. No PublicAPI.Unshipped.txt changes needed.
  • Performance: GetCurrentProcessId() snapshotted once in constructor. No hot-path allocations. WaitBoundedAsync clamps timeouts correctly.
  • Cross-TFM: #if NET8_0_OR_GREATER guard on AcceptTcpClientAsync(CancellationToken). RuntimeInformation used for OS detection on net462.
  • Resource Management: Every disposable (TcpListener, TcpClient, CancellationTokenSource, Process) has cleanup in both success and error paths. Pending accepts are neutralized. ServerCancellation is only disposed when safe.
  • Defensive Coding: Callback exceptions are wrapped as InnerException in MtpServerConnectionClosedException. Null task from callback is caught. Cancel() runs on thread pool to avoid blocking registration.
  • Error Handling: Shared teardown task wrapped in catch-all so it never faults. ObserveFailure prevents UnobservedTaskException. Every teardown helper is non-throwing.
  • Tests: 68 unit tests + acceptance tests covering argument shape, lifecycle, faults, cancellation, timeouts, idempotent disposal, notification-handler disposal, and the real end-to-end in-process path.
  • Documentation: Changelog, protocol intro, PACKAGE.md all updated. XML doc comments are thorough.

The refactoring of shared transport logic into MtpServerConnector and the IMtpServerHost abstraction is clean and prevents the two launch paths from drifting. The existing LaunchAsync(path) path is provably unchanged (same argument string, same failure messages, same teardown order).

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

Review tier: Balanced
Findings: 3 Medium severity

New issues introduced by this change (3)
SeverityFinding
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.csMtpJsonRpcConnection.Dispose() closes the socket and then waits up to its 5-second read-loop…
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.csShutdownAsync writes this nullable property from the teardown worker while callers can read…
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.cs — A canceled callback takes this branch without an inner exception, although the package…
What changed in this PR

Adds in-process launch support to the source-only MTP server-mode client for embedded/mobile hosts, addressing #10890 and enabling scenarios related to #9809.

Changes:

  • Adds callback-based in-process hosting with shared transport setup.
  • Adds bounded asynchronous shutdown and server exit-code reporting.
  • Adds documentation, unit coverage, and real-MSTest acceptance coverage.
FileDescription
MtpServerClientInProcessTests.csTests in-process lifecycle and protocol behavior.
FakeMtpServer.csSupports server-to-client dial-back mode.
MtpServerClientInProcessAcceptanceTests.csExercises a real in-process MSTest application.
MtpServerClientAcceptanceTests.csCovers external-process shutdown and exit code.
MtpServerClientSourcePackageConsumerTests.csExtends source-package compile coverage.
PACKAGE.mdDocuments embedded-host usage and lifecycle.
MtpServerProcess.csAdopts shared host and shutdown abstractions.
MtpServerInProcessHost.csImplements callback hosting and teardown.
MtpServerConnector.csCentralizes listener and transport setup.
MtpServerClientOptions.csAdds the shutdown timeout option.
MtpServerClientExceptions.csSupports preserved inner exceptions.
MtpServerClient.csExposes in-process launch and shutdown.
IMtpServerHost.csDefines common host ownership behavior.
IMtpServerClient.csAdds shutdown and exit-code members.
001-protocol-intro.mdDocuments reference-client launch modes.
Changelog-Platform.mdRecords the new embedded-host capability.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +254 to +258
Connection.Dispose();
SafeDispose(_client, _logger, "Disposing the accepted client socket");
MtpServerConnector.SafeStop(_listener, _logger);

bool stopped = await ShutdownServerAsync(_serverTask, _serverCancellation, _shutdownTimeout, _logger).ConfigureAwait(false);
/// Gets the exit code the hosted application returned, or <see langword="null"/> while it is still
/// running (or when it failed or was abandoned rather than returning one).
/// </summary>
public int? ExitCode { get; private set; }
Comment on lines +311 to +315
if (serverTask.IsCanceled)
{
return new MtpServerConnectionClosedException(
"The in-process Microsoft.Testing.Platform application was canceled before connecting back.");
}
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add in-process launch support to the MTP server-mode client

2 participants

@Evangelink
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Add in-process launch support to the MTP server-mode client by Evangelink · Pull Request #10898 · microsoft/testfx · GitHub
Skip to content

Add in-process launch support to the MTP server-mode client - #10898

Open
Amaury Levé (Evangelink) wants to merge 4 commits into
mainfrom
dev/amauryleve/mtp-in-process-client-launch
Open

Add in-process launch support to the MTP server-mode client#10898
Amaury Levé (Evangelink) wants to merge 4 commits into
mainfrom
dev/amauryleve/mtp-in-process-client-launch

Conversation

@Evangelink

Copy link
Copy Markdown
Member

Fixes#10890

Why

MtpServerClient.LaunchAsync(path) owns the loopback listener and starts the test application with Process.Start. That is right for IDE and desktop tooling, but unusable for embedded hosts — MAUI apps, Android/iOS test apps — where the MTP application already runs in the caller's process.

Today those hosts have to reimplement the listener, the --server/host/port arguments, the race between connection and startup failure, the serializer-before-formatter registration order, TcpMessageHandler + MtpJsonRpcConnection construction, and the exit / transport-close / server-completion shutdown dance. That defeats the point of shipping a canonical client and makes it easy to reintroduce bugs this package already solved: partial frame writes, ignored JSON-RPC errors, missing $/cancelRequest, unbounded waits, exception masking.

A concrete consumer is the DeviceRunners MSTest visual-runner work (mattleibow/DeviceRunners#157, #9809).

What

usingIMtpServerClientclient=awaitMtpServerClient.LaunchInProcessAsync(async(serverArgs,token)=>{ITestApplicationBuilderbuilder=awaitTestApplication.CreateBuilderAsync(serverArgs);builder.AddMSTest(()=>testAssemblies);usingITestApplicationapp=awaitbuilder.BuildAsync();returnawaitapp.RunAsync();},options,cancellationToken);awaitclient.InitializeAsync();awaitclient.DiscoverTestsAsync();awaitclient.RunTestsAsync();awaitclient.ExitAsync();awaitclient.ShutdownAsync();

The caller supplies only "how to run the application". The client keeps everything else: it binds the listener, generates the complete argument array (--server jsonrpc --client-host 127.0.0.1 --client-port <port> --no-banner), races the connect, builds the transport, and owns a bounded shutdown.

New API on the injected (still internal) surface:

MemberPurpose
MtpServerClient.LaunchInProcessAsync(callback, options, ct)The embedded-host launch path. Async only — blocking the launching thread can deadlock the application being launched.
IMtpServerClient.ShutdownAsync()Non-blocking teardown.
IMtpServerClient.ServerExitCodeThe value the application returned.
MtpServerClientOptions.ServerShutdownTimeoutGraceful shutdown bound (default 30s).

Internally, the transport setup was extracted from MtpServerProcess into MtpServerConnector, and both launch paths now sit behind IMtpServerHost, so they cannot drift.

Behavior worth reviewing

Ownership and shutdown. Both Dispose() and ShutdownAsync() join one lazily created shared teardown task, on both hosts. A Dispose that follows or races ShutdownAsync therefore returns only once the server has actually stopped, rather than reporting success while teardown is still running. Teardown runs on the thread pool, so ShutdownAsync never blocks and the synchronous Dispose cannot deadlock against a UI-thread continuation. Task.Run captures the execution context, so the connection's read-loop AsyncLocal marker still flows and disposing from a notification handler does not self-wait (covered by a test asserting < 4s against the connection's 5s read-loop timeout).

Bounded, and actually bounded. Teardown waits ServerShutdownTimeout, then cancels the callback's token, then a fixed 5s grace, then abandons and logs. CancellationTokenSource.Cancel() runs registrations synchronously, so the cancellation is started separately — otherwise a blocking caller registration would prevent the grace from ever starting and make the "bounded" wait unbounded. A failed launch skips the graceful wait entirely: nothing is connected, so there is no transport closure for the callback to observe.

Exception preservation. A callback that throws, is canceled, or returns before dialing back surfaces as MtpServerConnectionClosedException with the original as InnerException, instead of a misleading connection timeout. Every teardown helper is non-throwing, and the shared teardown task is wrapped so it can never fault — a faulted shared task would throw from every later disposal.

LaunchAsync(path) is unchanged. Verified against main: identical argument string, failure messages, stderr capture, exit fast-fail and teardown order. One earlier revision of this branch added a grace period to the accept race; review showed it only traded a precise failure for a vague one, so it was removed and the external path is now provably unchanged.

Browser/WASM. Both paths are loopback TCP. LaunchInProcessAsync fails fast with PlatformNotSupportedException there; this does not enable WASM hosting, and the docs say so.

Tests

  • 68 unit tests (net8.0) / 63 (net462, Jsonite path). New coverage: argument array shape; initialize → discover → run → exit; callback faulting synchronously and asynchronously; callback exiting with a code; null task; pre-canceled launch not invoking the callback; cancellation during connect canceling the callback token; connection timeout bounded by the grace rather than ServerShutdownTimeout; ShutdownAsync; ServerExitCode; disposal awaiting the callback; Dispose racing an in-flight ShutdownAsync; Dispose from a notification handler; idempotent disposal; callback faulting during shutdown; unresponsive callback abandoned within the bound; $/cancelRequest; stateful on/off; multi-request single connection; EnvironmentVariables ignored and warned. Stressed 6×/5× consecutively for flakiness.
  • New acceptance test where a single generated process is simultaneously the embedded host (compiling the packed source-only package) and a real MTP TestApplication with real MSTest over a real [TestClass] — discovering and running its own test over JSON-RPC with no Process.Start anywhere. It also asserts the server's reported process id equals its own.
  • Existing external-process acceptance test extended to cover ShutdownAsync + ServerExitCode; hostile-consumer compile oracle extended to bind the new API on net462/netstandard2.0/net5.0–net8.0.
  • Full regression: 13 platform source-package/consumer acceptance, 4 MSTest acceptance, 13 platform ServerTests. build.cmd -pack clean, 0 warnings.

Review history

Four independent review rounds (MTP/MSTest expert reviewer, a design reviewer, and two correctness reviewers) produced 3 major, 10 moderate and 10 minor findings, all addressed. Several were real bugs the tests then locked in — notably Process.ExitCode being unreadable after Process.Dispose(), a TcpListener socket leak when Start() failed, and a teardown path that could throw from a contract documented never to throw.

Open design questions

  1. No IAsyncDisposable. netstandard2.0/net462 would need Microsoft.Bcl.AsyncInterfaces, breaking the package's dependency-free promise. ShutdownAsync() is the substitute. Happy to revisit if the dependency is acceptable.
  2. MtpServerClientOptions is mode-mixed.EnvironmentVariables is external-process only; ServerShutdownTimeout is in-process only. Nesting per-transport options would age better, but EnvironmentVariables already shipped at the top level, so it cannot be done non-breakingly now.
  3. The 5s cancellation grace is a fixed constant, not an option.
  4. The two paths still build different argument shapes — deliberate, to keep the shipped external command line byte-identical. Commented at the call site.
  5. No ConnectAsync(TcpClient/Stream, options) factory — the existing MtpServerClient(MtpJsonRpcConnection, options) constructor already covers "wrap an existing transport", and the guidance is to minimize injected surface. Easy to add if wanted.

Embedded hosts such as MAUI or Android/iOS test apps cannot spawn a child
process, so `MtpServerClient.LaunchAsync(path)` was unusable for them and they
had to reimplement the listener, the server-mode arguments, the connect race,
the serializer/formatter ordering, the transport and the shutdown coordination
by hand -- reintroducing the bugs the canonical client already solves.
Add `MtpServerClient.LaunchInProcessAsync(callback, options, cancellationToken)`.
The client keeps ownership of everything except "how to run the application":
it binds the loopback listener, generates the complete server-mode argument
array, races the connect against callback failure/completion, caller
cancellation and the connection timeout, and owns a bounded shutdown that
closes the transport, then cancels the callback token, then abandons it rather
than hanging the caller. A callback failure before connection is surfaced as
`MtpServerConnectionClosedException` with the caller's exception preserved as
the inner exception, and teardown failures are only logged so they can never
replace the primary failure.
The shared transport setup is extracted from `MtpServerProcess` into
`MtpServerConnector` and both launch paths now flow through `IMtpServerHost`,
so the external-process behavior is unchanged while the two paths cannot drift.
The one behavior change is an improvement shared by both: when the server is
seen to have stopped, a still-pending accept gets a bounded final grace so a
connection established just before the stop is not discarded in favor of a
misleading "stopped before connecting back" failure.
The path is loopback TCP, so it fails fast with `PlatformNotSupportedException`
on browser/WASM; it does not enable WASM hosting.
Fixes#10890
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two independent reviews of the in-process launch path raised the same top
finding: `Dispose()` waits for the hosted application synchronously on the
calling thread, and on the very platforms this feature targets (MAUI, Android,
iOS) a multi-second block on the UI thread trips the ANR / watchdog. Add
`IMtpServerClient.ShutdownAsync()` (and `IMtpServerHost.ShutdownAsync`) so the
same teardown can be awaited instead. `IAsyncDisposable` stays rejected --
netstandard2.0 and net462 would need `Microsoft.Bcl.AsyncInterfaces`, which
breaks the package's dependency-free promise -- but rejecting the interface is
not a reason to have no asynchronous shutdown at all. `Dispose()` remains, is
still idempotent, and returns immediately after `ShutdownAsync`.
Remove the accept grace period. The previous commit let a still-pending accept
win for ~100ms after the server was seen to have stopped, on the theory that a
connection established just before the stop should not be discarded. That
theory does not hold: if the server has stopped, the socket belongs to a dead
peer, so the grace only traded a precise failure (exit code plus captured
stderr) for a generic connection-closed error on the first request, and its
uncancellable delay could let the server failure beat a concurrent caller
cancellation. Dropping it also makes the external-process path provably
unchanged, so the changelog no longer needs a `Changed` entry.
Other review fixes:
* Expose `IMtpServerClient.ServerExitCode`. The callback signature already
demanded a `Task<int>`, but after a successful session the value was
unreachable, so an embedded host whose `Main` must return it had to capture
it in a closure.
* Do not dispose the server's `CancellationTokenSource` when the callback was
abandoned while still running: it holds the token, and `token.WaitHandle` or
`CreateLinkedTokenSource` would then throw `ObjectDisposedException` inside
the caller's own code.
* Skip the graceful wait entirely on a failed launch. Nothing is connected, so
there is no transport closure for the callback to observe; only the fixed
cancellation grace applies and an unwinding caller no longer pays
`ServerShutdownTimeout`.
* Clamp bounded waits instead of trusting the caller's `TimeSpan`. A negative
value (or `Timeout.InfiniteTimeSpan`) made `Task.Delay` throw from a path
documented never to throw; an oversized one is capped to the largest delay
.NET Framework accepts.
* Move `TcpListener.Start()` inside the cleanup `try`: it creates the socket
before binding, so a bind failure leaked it because the caller never received
a listener it could stop.
* Log a late failure from an abandoned callback rather than only observing it.
* Cache the in-process host's `ProcessId` instead of allocating a `Process` per
property read.
* Document that `Dispose()` blocks, that cancellation is bounded rather than
immediate, and why the two launch paths still build different argument
shapes; fix the shadowed `cancellationToken` in the PACKAGE.md sample.
Tests: cover `ShutdownAsync`, `ServerExitCode`, and fail-fast on a server that
stops without connecting; make `Dispose_IsIdempotent` able to fail by asserting
on observed transport closes rather than callback invocations; replace the
hand-rolled throws helper with `Assert.ThrowsExactlyAsync`; tighten the
shutdown-bound assertion; and stop leaking `Process` handles.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A third review round found three ways the teardown contract did not hold up
under concurrency.
`Dispose()` and `ShutdownAsync()` each had their own `Interlocked.Exchange`
guard, so whichever call lost the race returned while teardown was still
running -- reporting to its caller that the application had stopped when it had
not. Replace both guards with one lazily started, shared teardown task: every
entry point now joins the same task, so `Dispose` after (or racing)
`ShutdownAsync` returns only once the application has actually stopped, and the
whole thing stays idempotent. `MtpServerClient` no longer keeps its own guard
either; it delegates to the host, whose teardown is the single source of truth.
`ShutdownAsync()` could still block the very thread it exists to protect: it
closed the transport before its first await, and closing the connection waits
up to five seconds for the read loop. The shared task is started with
`Task.Run`, so the whole teardown -- transport close included -- runs on the
thread pool and `ShutdownAsync` returns immediately. `MtpServerProcess`
likewise now runs its (bounded but synchronous) kill off the calling thread
rather than pretending to be async while blocking.
`CancellationTokenSource.Cancel()` executes registrations synchronously on the
calling thread, so a caller registration that blocked would prevent the
five-second cancellation grace from ever starting and make the "bounded"
shutdown unbounded. Start the cancellation separately and begin the grace
regardless. When a registration is still executing once the callback has
finished, the source is reported as unsafe to dispose for the same reason an
abandoned callback is: leaking one `CancellationTokenSource` beats a
use-after-dispose inside the caller's code.
Adds a test that a `Dispose` racing an in-flight `ShutdownAsync` blocks until
the shared teardown completes, and that both share one teardown.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… fix ExitCode
A fourth review round found that only the in-process host had been converted to
the shared-teardown design. `MtpServerProcess` kept its old early-return guard,
so on the external-process path a `Dispose()` that followed or raced
`ShutdownAsync()` returned while `SafeKill`'s bounded `WaitForExit` was still
running on another thread -- losing exactly the guarantee that wait exists for
(a caller may delete the application directory immediately after disposal), and
making `await ShutdownAsync()` report that the server had stopped when it had
not. That contradicted the comment the previous commit added to
`MtpServerClient.Dispose`, which claimed both paths joined an in-flight
teardown. Convert `MtpServerProcess` to the same lazily created shared task, so
the contract is now uniform across both implementations of `IMtpServerHost`,
and reconcile the interface docs, which previously described the opposite rule.
Adding real coverage for `ShutdownAsync` on the external-process path then
surfaced a genuine bug: `MtpServerProcess.ExitCode` was always `null` after
teardown, because a `Process` cannot be queried once disposed. Capture the exit
code during teardown instead -- before the kill, so an application that already
exited on its own reports its real code rather than the kill's, and before
`Process.Dispose()`, after which nothing is readable.
Also fixes a race in the in-process test fixture that made the suite flaky on
net462: the callback published its `FakeMtpServer` only after its own connect
call returned, but the client's accept can complete first, so a test could
reach `Value` before the callback had set it. The fixture now exposes a
`Connected` signal the launch helper awaits. 6/6 clean net462 runs and 5/5
clean net8.0 runs afterwards.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI balanced review requested due to automatic review settings August 31, 2026 19:22
Comment on lines +182 to +193
if (serverTask is not null)
{
// 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.
if (!await ShutdownServerAsync(serverTask, serverCancellation!, TimeSpan.Zero, logger).ConfigureAwait(false))
{
// 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;
}
}
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Expert test review — PR #10898

GradeTestMutationNotesHow to improve
B (80–89)new MtpServerClientInProcessTests.
Dispose_CallbackFaultsDuringShutdown_
DoesNotThrow
2/3 killedReads server.Completion.Exception but never asserts on it, so a mutation changing the faulted exception's identity/message survives.Assert the captured exception's message/type instead of discarding it after touching .Exception.
B (80–89)new MtpServerClientInProcessTests.
LaunchInProcessAsync_HonorsTheStatefulOption
3/4 killedOnly asserts the sent initialize args; a mutation dropping the negotiated round-trip value on client.Capabilities is not caught.Also assert client.Capabilities.IsStateful reflects the negotiated round-trip value, not only the sent request.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_PassesCompleteServerModeArguments
5/5 killedVerifies exact ordered arguments, dynamic port and fixed count; strong regression guard for the generated argument array.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_DrivesInitializeDiscoverRunAndExit
6/6 killedEnd-to-end drive through initialize/discover/run/exit with distinct assertions per state transition.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackFaultsBeforeConnecting_
PreservesCallbackException
2/2 killedAsserts the exact exception instance is preserved as inner exception, not just its type.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackFaultsAsynchronouslyBeforeConnecting_
PreservesCallbackException
2/2 killedCovers the async-throw variant with the same identity assertion as the sync case.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackExitsWithoutConnecting_
FailsFastInsteadOfWaitingOutTheTimeout
3/3 killedChecks both the reported exit code in the message and a timing bound guarding against a slow-timeout regression.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackExitsBeforeConnecting_
ReportsExitCode
2/2 killedAsserts the specific exit code surfaces in the exception message.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackReturnsNullTask_Fails
2/2 killedAsserts the specific inner exception type for a misbehaving callback returning a null task.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_NullCallback_Throws
1/1 killedFocused single-assertion guard-clause test using the exact exception type.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_AlreadyCanceled_
DoesNotInvokeCallback
2/2 killedVerifies both the cancellation exception and, via an interlocked counter, that the callback never ran.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CanceledWhileConnecting_
CancelsTheCallbackToken
2/2 killedConfirms both the caller-side cancellation and that the callback's own token observed cancellation.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_ConnectionTimeoutElapses_
FailsWithTimeoutMessage
3/3 killedDistinguishes the connection timeout from the deliberately huge shutdown timeout via message and elapsed-time bound.
A (90–100)new MtpServerClientInProcessTests.
Dispose_ClosesTransportAndAwaitsTheCallback
4/4 killedChecks pre/post completion state, the callback's returned exit code, and the client's reported exit code.
A (90–100)new MtpServerClientInProcessTests.
ShutdownAsync_ClosesTransportAndAwaitsTheCallback_
WithoutBlocking
4/4 killedVerifies await-completion, exit code, a fast follow-up Dispose, and single-teardown count together.
A (90–100)new MtpServerClientInProcessTests.
Dispose_FromANotificationHandler_
DoesNotSelfWaitOnTheReadLoop
1/1 killedTargeted timing assertion guards against a specific re-entrancy deadlock regression.
A (90–100)new MtpServerClientInProcessTests.
Dispose_WhileShutdownAsyncIsInFlight_
WaitsForTheSameTeardown
3/3 killedUses a controlled release gate to prove the racing Dispose genuinely blocks, then confirms a single shared teardown.
A (90–100)new MtpServerClientInProcessTests.
Dispose_IsIdempotent
3/3 killedConfirms exactly one transport close and one completion despite three Dispose calls, plus a fast-return bound.
A (90–100)new MtpServerClientInProcessTests.
Dispose_CallbackIgnoresShutdown_
ReturnsWithinTheDocumentedBound
2/2 killedVerifies both the documented abandonment time bound and the logged "abandoning" diagnostic.
A (90–100)new MtpServerClientInProcessTests.
RunTestsAsync_Canceled_
SendsCancelRequestToTheHostedApplication
2/2 killedConfirms both the client-side cancellation exception and the wire-level cancel notification reaching the fake server.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_MultipleRequestsOnOneSession_
ReuseTheSameConnection
3/3 killedChecks keep-alive negotiation, single-connection reuse count, and total request count together.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_IgnoresEnvironmentVariablesAndWarns
1/1 killedAsserts the specific warning content naming the ignored option.
A (90–100)new MtpServerClientInProcessAcceptanceTests.
InProcessHost_
DiscoversAndRunsItsOwnMSTestNodes_
WithoutStartingAProcess
5/5 killedReal end-to-end embedded-host run asserting build success, exit code, and each marker line the generated app emits.
A (90–100)mod MtpServerClientAcceptanceTests.
DiscoverAndRun_ViaSourcePackageClient_
ReportsExpectedTestNode
1/1 killedNew lines assert the external-process ShutdownAsync/Dispose share a teardown and report an exit code.
A (90–100)mod MtpServerClientSourcePackageConsumerTests.
HostileConsumer_
CompilesAgainstPackedSource
N/ACompile-only guard; added lines correctly extend surface coverage to ShutdownAsync and the in-process launch path.

This advisory comment was generated automatically. Grades are heuristic
and informational — they do not block merging. Suggestions on the Files
changed tab can be applied with one click. Re-run with
/review-tests.

🤖 Automated content by GitHub Copilot. Generated by the Test Reviewer on PR (on open / sync) workflow. · auto · 159.6 AIC · ⌖ 2.87 AIC · ⊞ 16.9K · [◷]( · )

@github-actions

Copy link
Copy Markdown
Contributor

🧵 Parallel-safety audit — PR #10898

Parallelization — audited assemblies:

Test assemblyScopeWorkersAnalyzer coverage
MSTest.Acceptance.IntegrationTestsMethodLevel (default [assembly: Parallelize(Scope = ExecutionScope.MethodLevel, Workers = 0)] in Program.cs)CPU countcoverable once the parallel-safety analyzers ship (attribute-based opt-in)
Microsoft.Testing.Platform.Acceptance.IntegrationTestsMethodLevel (Program.cs)CPU countcoverable once shipped
Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTestsMethodLevel (Program.cs)CPU countcoverable once shipped

This PR did not touch any .runsettings/testconfig.json/Directory.Build.* parallelization config — no assembly's scope changed.

Findings: A (global-state) 0 · B (paths) 0 · C (declaration) 0 · D (over-serialization) 0 — by severity: Critical 0 · High 0 · Warning 0 · Info 0.

No unsafe call sites found in the lines this PR added or modified.

  • MtpServerClientAcceptanceTests.cs — added lines only call client.ShutdownAsync() / assert ServerExitCode; no process-global state touched.
  • MtpServerClientSourcePackageConsumerTests.cs — added lines call into an isolated generated test asset (DriveAsync/DriveInProcessAsync); no shared/relative paths, env vars, culture, or console state mutated at the assembly level (Console.WriteLine here writes to the generated app's own redirected output, not the auditing process).
  • MtpServerClientInProcessAcceptanceTests.cs (new) — uses TestAsset.GenerateAssetAsync, which allocates a unique GUID-suffixed TempDirectory per instance (TestAsset.cs:19-26, throws if a path already exists) — no shared-path collision. CreateChildEnvironment() builds a fresh Dictionary passed only to the spawned child process's environment, not Environment.SetEnvironmentVariable, so it does not mutate this process's state.
  • FakeMtpServer.cs / MtpServerClientInProcessTests.cs (new) — each test constructs its own TcpListener/FakeMtpServer bound to an OS-assigned loopback port (new TcpListener(IPAddress.Loopback, 0)), so there is no fixed-port collision between concurrently-running tests; no static/process-global state is written.

Nothing here implies a testability or smell concern beyond this audit's scope.

Advisory only — heuristic, non-blocking. Re-run with /parallel-audit. This audit answers "is it parallel-safe?"; for testability, smells, or flakiness see the detect-static-dependencies / test-smell-detection / test-anti-patterns analyses.

🤖 Automated content by GitHub Copilot. Generated by the Parallel-safety audit on PR (on open / sync) workflow. · auto · 117.1 AIC · ⌖ 3.53 AIC · ⊞ 24.8K · [◷]( · )

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated content by GitHub Copilot. Generated by the Test Reviewer on PR (on open / sync) workflow. · auto · 159.6 AIC · ⌖ 2.87 AIC · ⊞ 16.9K ·

client.Dispose();

Assert.IsTrue(server.Completion.IsFaulted);
_ = server.Completion.Exception;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test review · Grade B (80–89) — Reads server.Completion.Exception but never asserts on it, so a mutation changing the faulted exception's identity/message would survive.

Assert the captured exception's message/type instead of discarding it after touching .Exception.

Suggested change
_=server.Completion.Exception;
Exception?faultException=server.Completion.Exception?.GetBaseException();
Assert.IsInstanceOfType<InvalidOperationException>(faultException);
Assert.AreEqual("The application failed while shutting down.",faultException!.Message);

? typed
: SerializerUtilities.Deserialize<InitializeRequestArgs>((IDictionary<string, object?>)initialize.Params!);

Assert.AreEqual(isStateful, args.Capabilities.IsStateful, "The in-process path must forward the client's stateful capability unchanged.");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test review · Grade B (80–89) — Only asserts the sent initialize args; a mutation dropping the negotiated round-trip value on client.Capabilities is not caught.

Also assert client.Capabilities.IsStateful reflects the negotiated round-trip value, not only the sent request.

Suggested change
Assert.AreEqual(isStateful,args.Capabilities.IsStateful,"The in-process path must forward the client's stateful capability unchanged.");
Assert.AreEqual(isStateful,args.Capabilities.IsStateful,"The in-process path must forward the client's stateful capability unchanged.");
Assert.AreEqual(isStateful,capabilities.IsStateful,"The negotiated capability the client stores must reflect the server's round trip, not just the sent request.");

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

🤖 Automated review by GitHub Copilot. Generated by the Expert Code Review workflow. To request a follow-up action, reply by tagging @copilot directly.

✅ 22/22 dimensions clean — no findings.

Summary: This PR adds MtpServerClient.LaunchInProcessAsync for embedded hosts (MAUI, Android/iOS) that cannot Process.Start. The implementation is thorough and well-structured:

  • Threading & Concurrency: Shutdown is idempotent via lock + lazy Task, Volatile.Read/Write for cross-thread exit code, Task.Run avoids sync-context deadlocks. No shared mutable state without synchronization.
  • Security & IPC: Loopback TCP only, no command-line injection surface (arguments are array-based), WASM fails fast with PlatformNotSupportedException.
  • Public API: All new API is internal (source-only package). No init accessors. No PublicAPI.Unshipped.txt changes needed.
  • Performance: GetCurrentProcessId() snapshotted once in constructor. No hot-path allocations. WaitBoundedAsync clamps timeouts correctly.
  • Cross-TFM: #if NET8_0_OR_GREATER guard on AcceptTcpClientAsync(CancellationToken). RuntimeInformation used for OS detection on net462.
  • Resource Management: Every disposable (TcpListener, TcpClient, CancellationTokenSource, Process) has cleanup in both success and error paths. Pending accepts are neutralized. ServerCancellation is only disposed when safe.
  • Defensive Coding: Callback exceptions are wrapped as InnerException in MtpServerConnectionClosedException. Null task from callback is caught. Cancel() runs on thread pool to avoid blocking registration.
  • Error Handling: Shared teardown task wrapped in catch-all so it never faults. ObserveFailure prevents UnobservedTaskException. Every teardown helper is non-throwing.
  • Tests: 68 unit tests + acceptance tests covering argument shape, lifecycle, faults, cancellation, timeouts, idempotent disposal, notification-handler disposal, and the real end-to-end in-process path.
  • Documentation: Changelog, protocol intro, PACKAGE.md all updated. XML doc comments are thorough.

The refactoring of shared transport logic into MtpServerConnector and the IMtpServerHost abstraction is clean and prevents the two launch paths from drifting. The existing LaunchAsync(path) path is provably unchanged (same argument string, same failure messages, same teardown order).

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

Review tier: Balanced
Findings: 3 Medium severity

New issues introduced by this change (3)
SeverityFinding
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.csMtpJsonRpcConnection.Dispose() closes the socket and then waits up to its 5-second read-loop…
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.csShutdownAsync writes this nullable property from the teardown worker while callers can read…
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.cs — A canceled callback takes this branch without an inner exception, although the package…
What changed in this PR

Adds in-process launch support to the source-only MTP server-mode client for embedded/mobile hosts, addressing #10890 and enabling scenarios related to #9809.

Changes:

  • Adds callback-based in-process hosting with shared transport setup.
  • Adds bounded asynchronous shutdown and server exit-code reporting.
  • Adds documentation, unit coverage, and real-MSTest acceptance coverage.
FileDescription
MtpServerClientInProcessTests.csTests in-process lifecycle and protocol behavior.
FakeMtpServer.csSupports server-to-client dial-back mode.
MtpServerClientInProcessAcceptanceTests.csExercises a real in-process MSTest application.
MtpServerClientAcceptanceTests.csCovers external-process shutdown and exit code.
MtpServerClientSourcePackageConsumerTests.csExtends source-package compile coverage.
PACKAGE.mdDocuments embedded-host usage and lifecycle.
MtpServerProcess.csAdopts shared host and shutdown abstractions.
MtpServerInProcessHost.csImplements callback hosting and teardown.
MtpServerConnector.csCentralizes listener and transport setup.
MtpServerClientOptions.csAdds the shutdown timeout option.
MtpServerClientExceptions.csSupports preserved inner exceptions.
MtpServerClient.csExposes in-process launch and shutdown.
IMtpServerHost.csDefines common host ownership behavior.
IMtpServerClient.csAdds shutdown and exit-code members.
001-protocol-intro.mdDocuments reference-client launch modes.
Changelog-Platform.mdRecords the new embedded-host capability.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +254 to +258
Connection.Dispose();
SafeDispose(_client, _logger, "Disposing the accepted client socket");
MtpServerConnector.SafeStop(_listener, _logger);

bool stopped = await ShutdownServerAsync(_serverTask, _serverCancellation, _shutdownTimeout, _logger).ConfigureAwait(false);
/// Gets the exit code the hosted application returned, or <see langword="null"/> while it is still
/// running (or when it failed or was abandoned rather than returning one).
/// </summary>
public int? ExitCode { get; private set; }
Comment on lines +311 to +315
if (serverTask.IsCanceled)
{
return new MtpServerConnectionClosedException(
"The in-process Microsoft.Testing.Platform application was canceled before connecting back.");
}
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add in-process launch support to the MTP server-mode client

2 participants

@Evangelink
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add in-process launch support to the MTP server-mode client by Evangelink · Pull Request #10898 · microsoft/testfx · GitHub
Skip to content

Add in-process launch support to the MTP server-mode client - #10898

Open
Amaury Levé (Evangelink) wants to merge 4 commits into
mainfrom
dev/amauryleve/mtp-in-process-client-launch
Open

Add in-process launch support to the MTP server-mode client#10898
Amaury Levé (Evangelink) wants to merge 4 commits into
mainfrom
dev/amauryleve/mtp-in-process-client-launch

Conversation

@Evangelink

Copy link
Copy Markdown
Member

Fixes#10890

Why

MtpServerClient.LaunchAsync(path) owns the loopback listener and starts the test application with Process.Start. That is right for IDE and desktop tooling, but unusable for embedded hosts — MAUI apps, Android/iOS test apps — where the MTP application already runs in the caller's process.

Today those hosts have to reimplement the listener, the --server/host/port arguments, the race between connection and startup failure, the serializer-before-formatter registration order, TcpMessageHandler + MtpJsonRpcConnection construction, and the exit / transport-close / server-completion shutdown dance. That defeats the point of shipping a canonical client and makes it easy to reintroduce bugs this package already solved: partial frame writes, ignored JSON-RPC errors, missing $/cancelRequest, unbounded waits, exception masking.

A concrete consumer is the DeviceRunners MSTest visual-runner work (mattleibow/DeviceRunners#157, #9809).

What

usingIMtpServerClientclient=awaitMtpServerClient.LaunchInProcessAsync(async(serverArgs,token)=>{ITestApplicationBuilderbuilder=awaitTestApplication.CreateBuilderAsync(serverArgs);builder.AddMSTest(()=>testAssemblies);usingITestApplicationapp=awaitbuilder.BuildAsync();returnawaitapp.RunAsync();},options,cancellationToken);awaitclient.InitializeAsync();awaitclient.DiscoverTestsAsync();awaitclient.RunTestsAsync();awaitclient.ExitAsync();awaitclient.ShutdownAsync();

The caller supplies only "how to run the application". The client keeps everything else: it binds the listener, generates the complete argument array (--server jsonrpc --client-host 127.0.0.1 --client-port <port> --no-banner), races the connect, builds the transport, and owns a bounded shutdown.

New API on the injected (still internal) surface:

MemberPurpose
MtpServerClient.LaunchInProcessAsync(callback, options, ct)The embedded-host launch path. Async only — blocking the launching thread can deadlock the application being launched.
IMtpServerClient.ShutdownAsync()Non-blocking teardown.
IMtpServerClient.ServerExitCodeThe value the application returned.
MtpServerClientOptions.ServerShutdownTimeoutGraceful shutdown bound (default 30s).

Internally, the transport setup was extracted from MtpServerProcess into MtpServerConnector, and both launch paths now sit behind IMtpServerHost, so they cannot drift.

Behavior worth reviewing

Ownership and shutdown. Both Dispose() and ShutdownAsync() join one lazily created shared teardown task, on both hosts. A Dispose that follows or races ShutdownAsync therefore returns only once the server has actually stopped, rather than reporting success while teardown is still running. Teardown runs on the thread pool, so ShutdownAsync never blocks and the synchronous Dispose cannot deadlock against a UI-thread continuation. Task.Run captures the execution context, so the connection's read-loop AsyncLocal marker still flows and disposing from a notification handler does not self-wait (covered by a test asserting < 4s against the connection's 5s read-loop timeout).

Bounded, and actually bounded. Teardown waits ServerShutdownTimeout, then cancels the callback's token, then a fixed 5s grace, then abandons and logs. CancellationTokenSource.Cancel() runs registrations synchronously, so the cancellation is started separately — otherwise a blocking caller registration would prevent the grace from ever starting and make the "bounded" wait unbounded. A failed launch skips the graceful wait entirely: nothing is connected, so there is no transport closure for the callback to observe.

Exception preservation. A callback that throws, is canceled, or returns before dialing back surfaces as MtpServerConnectionClosedException with the original as InnerException, instead of a misleading connection timeout. Every teardown helper is non-throwing, and the shared teardown task is wrapped so it can never fault — a faulted shared task would throw from every later disposal.

LaunchAsync(path) is unchanged. Verified against main: identical argument string, failure messages, stderr capture, exit fast-fail and teardown order. One earlier revision of this branch added a grace period to the accept race; review showed it only traded a precise failure for a vague one, so it was removed and the external path is now provably unchanged.

Browser/WASM. Both paths are loopback TCP. LaunchInProcessAsync fails fast with PlatformNotSupportedException there; this does not enable WASM hosting, and the docs say so.

Tests

  • 68 unit tests (net8.0) / 63 (net462, Jsonite path). New coverage: argument array shape; initialize → discover → run → exit; callback faulting synchronously and asynchronously; callback exiting with a code; null task; pre-canceled launch not invoking the callback; cancellation during connect canceling the callback token; connection timeout bounded by the grace rather than ServerShutdownTimeout; ShutdownAsync; ServerExitCode; disposal awaiting the callback; Dispose racing an in-flight ShutdownAsync; Dispose from a notification handler; idempotent disposal; callback faulting during shutdown; unresponsive callback abandoned within the bound; $/cancelRequest; stateful on/off; multi-request single connection; EnvironmentVariables ignored and warned. Stressed 6×/5× consecutively for flakiness.
  • New acceptance test where a single generated process is simultaneously the embedded host (compiling the packed source-only package) and a real MTP TestApplication with real MSTest over a real [TestClass] — discovering and running its own test over JSON-RPC with no Process.Start anywhere. It also asserts the server's reported process id equals its own.
  • Existing external-process acceptance test extended to cover ShutdownAsync + ServerExitCode; hostile-consumer compile oracle extended to bind the new API on net462/netstandard2.0/net5.0–net8.0.
  • Full regression: 13 platform source-package/consumer acceptance, 4 MSTest acceptance, 13 platform ServerTests. build.cmd -pack clean, 0 warnings.

Review history

Four independent review rounds (MTP/MSTest expert reviewer, a design reviewer, and two correctness reviewers) produced 3 major, 10 moderate and 10 minor findings, all addressed. Several were real bugs the tests then locked in — notably Process.ExitCode being unreadable after Process.Dispose(), a TcpListener socket leak when Start() failed, and a teardown path that could throw from a contract documented never to throw.

Open design questions

  1. No IAsyncDisposable. netstandard2.0/net462 would need Microsoft.Bcl.AsyncInterfaces, breaking the package's dependency-free promise. ShutdownAsync() is the substitute. Happy to revisit if the dependency is acceptable.
  2. MtpServerClientOptions is mode-mixed.EnvironmentVariables is external-process only; ServerShutdownTimeout is in-process only. Nesting per-transport options would age better, but EnvironmentVariables already shipped at the top level, so it cannot be done non-breakingly now.
  3. The 5s cancellation grace is a fixed constant, not an option.
  4. The two paths still build different argument shapes — deliberate, to keep the shipped external command line byte-identical. Commented at the call site.
  5. No ConnectAsync(TcpClient/Stream, options) factory — the existing MtpServerClient(MtpJsonRpcConnection, options) constructor already covers "wrap an existing transport", and the guidance is to minimize injected surface. Easy to add if wanted.

Embedded hosts such as MAUI or Android/iOS test apps cannot spawn a child
process, so `MtpServerClient.LaunchAsync(path)` was unusable for them and they
had to reimplement the listener, the server-mode arguments, the connect race,
the serializer/formatter ordering, the transport and the shutdown coordination
by hand -- reintroducing the bugs the canonical client already solves.
Add `MtpServerClient.LaunchInProcessAsync(callback, options, cancellationToken)`.
The client keeps ownership of everything except "how to run the application":
it binds the loopback listener, generates the complete server-mode argument
array, races the connect against callback failure/completion, caller
cancellation and the connection timeout, and owns a bounded shutdown that
closes the transport, then cancels the callback token, then abandons it rather
than hanging the caller. A callback failure before connection is surfaced as
`MtpServerConnectionClosedException` with the caller's exception preserved as
the inner exception, and teardown failures are only logged so they can never
replace the primary failure.
The shared transport setup is extracted from `MtpServerProcess` into
`MtpServerConnector` and both launch paths now flow through `IMtpServerHost`,
so the external-process behavior is unchanged while the two paths cannot drift.
The one behavior change is an improvement shared by both: when the server is
seen to have stopped, a still-pending accept gets a bounded final grace so a
connection established just before the stop is not discarded in favor of a
misleading "stopped before connecting back" failure.
The path is loopback TCP, so it fails fast with `PlatformNotSupportedException`
on browser/WASM; it does not enable WASM hosting.
Fixes#10890
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two independent reviews of the in-process launch path raised the same top
finding: `Dispose()` waits for the hosted application synchronously on the
calling thread, and on the very platforms this feature targets (MAUI, Android,
iOS) a multi-second block on the UI thread trips the ANR / watchdog. Add
`IMtpServerClient.ShutdownAsync()` (and `IMtpServerHost.ShutdownAsync`) so the
same teardown can be awaited instead. `IAsyncDisposable` stays rejected --
netstandard2.0 and net462 would need `Microsoft.Bcl.AsyncInterfaces`, which
breaks the package's dependency-free promise -- but rejecting the interface is
not a reason to have no asynchronous shutdown at all. `Dispose()` remains, is
still idempotent, and returns immediately after `ShutdownAsync`.
Remove the accept grace period. The previous commit let a still-pending accept
win for ~100ms after the server was seen to have stopped, on the theory that a
connection established just before the stop should not be discarded. That
theory does not hold: if the server has stopped, the socket belongs to a dead
peer, so the grace only traded a precise failure (exit code plus captured
stderr) for a generic connection-closed error on the first request, and its
uncancellable delay could let the server failure beat a concurrent caller
cancellation. Dropping it also makes the external-process path provably
unchanged, so the changelog no longer needs a `Changed` entry.
Other review fixes:
* Expose `IMtpServerClient.ServerExitCode`. The callback signature already
demanded a `Task<int>`, but after a successful session the value was
unreachable, so an embedded host whose `Main` must return it had to capture
it in a closure.
* Do not dispose the server's `CancellationTokenSource` when the callback was
abandoned while still running: it holds the token, and `token.WaitHandle` or
`CreateLinkedTokenSource` would then throw `ObjectDisposedException` inside
the caller's own code.
* Skip the graceful wait entirely on a failed launch. Nothing is connected, so
there is no transport closure for the callback to observe; only the fixed
cancellation grace applies and an unwinding caller no longer pays
`ServerShutdownTimeout`.
* Clamp bounded waits instead of trusting the caller's `TimeSpan`. A negative
value (or `Timeout.InfiniteTimeSpan`) made `Task.Delay` throw from a path
documented never to throw; an oversized one is capped to the largest delay
.NET Framework accepts.
* Move `TcpListener.Start()` inside the cleanup `try`: it creates the socket
before binding, so a bind failure leaked it because the caller never received
a listener it could stop.
* Log a late failure from an abandoned callback rather than only observing it.
* Cache the in-process host's `ProcessId` instead of allocating a `Process` per
property read.
* Document that `Dispose()` blocks, that cancellation is bounded rather than
immediate, and why the two launch paths still build different argument
shapes; fix the shadowed `cancellationToken` in the PACKAGE.md sample.
Tests: cover `ShutdownAsync`, `ServerExitCode`, and fail-fast on a server that
stops without connecting; make `Dispose_IsIdempotent` able to fail by asserting
on observed transport closes rather than callback invocations; replace the
hand-rolled throws helper with `Assert.ThrowsExactlyAsync`; tighten the
shutdown-bound assertion; and stop leaking `Process` handles.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A third review round found three ways the teardown contract did not hold up
under concurrency.
`Dispose()` and `ShutdownAsync()` each had their own `Interlocked.Exchange`
guard, so whichever call lost the race returned while teardown was still
running -- reporting to its caller that the application had stopped when it had
not. Replace both guards with one lazily started, shared teardown task: every
entry point now joins the same task, so `Dispose` after (or racing)
`ShutdownAsync` returns only once the application has actually stopped, and the
whole thing stays idempotent. `MtpServerClient` no longer keeps its own guard
either; it delegates to the host, whose teardown is the single source of truth.
`ShutdownAsync()` could still block the very thread it exists to protect: it
closed the transport before its first await, and closing the connection waits
up to five seconds for the read loop. The shared task is started with
`Task.Run`, so the whole teardown -- transport close included -- runs on the
thread pool and `ShutdownAsync` returns immediately. `MtpServerProcess`
likewise now runs its (bounded but synchronous) kill off the calling thread
rather than pretending to be async while blocking.
`CancellationTokenSource.Cancel()` executes registrations synchronously on the
calling thread, so a caller registration that blocked would prevent the
five-second cancellation grace from ever starting and make the "bounded"
shutdown unbounded. Start the cancellation separately and begin the grace
regardless. When a registration is still executing once the callback has
finished, the source is reported as unsafe to dispose for the same reason an
abandoned callback is: leaking one `CancellationTokenSource` beats a
use-after-dispose inside the caller's code.
Adds a test that a `Dispose` racing an in-flight `ShutdownAsync` blocks until
the shared teardown completes, and that both share one teardown.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… fix ExitCode
A fourth review round found that only the in-process host had been converted to
the shared-teardown design. `MtpServerProcess` kept its old early-return guard,
so on the external-process path a `Dispose()` that followed or raced
`ShutdownAsync()` returned while `SafeKill`'s bounded `WaitForExit` was still
running on another thread -- losing exactly the guarantee that wait exists for
(a caller may delete the application directory immediately after disposal), and
making `await ShutdownAsync()` report that the server had stopped when it had
not. That contradicted the comment the previous commit added to
`MtpServerClient.Dispose`, which claimed both paths joined an in-flight
teardown. Convert `MtpServerProcess` to the same lazily created shared task, so
the contract is now uniform across both implementations of `IMtpServerHost`,
and reconcile the interface docs, which previously described the opposite rule.
Adding real coverage for `ShutdownAsync` on the external-process path then
surfaced a genuine bug: `MtpServerProcess.ExitCode` was always `null` after
teardown, because a `Process` cannot be queried once disposed. Capture the exit
code during teardown instead -- before the kill, so an application that already
exited on its own reports its real code rather than the kill's, and before
`Process.Dispose()`, after which nothing is readable.
Also fixes a race in the in-process test fixture that made the suite flaky on
net462: the callback published its `FakeMtpServer` only after its own connect
call returned, but the client's accept can complete first, so a test could
reach `Value` before the callback had set it. The fixture now exposes a
`Connected` signal the launch helper awaits. 6/6 clean net462 runs and 5/5
clean net8.0 runs afterwards.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI balanced review requested due to automatic review settings August 31, 2026 19:22
Comment on lines +182 to +193
if (serverTask is not null)
{
// 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.
if (!await ShutdownServerAsync(serverTask, serverCancellation!, TimeSpan.Zero, logger).ConfigureAwait(false))
{
// 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;
}
}
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Expert test review — PR #10898

GradeTestMutationNotesHow to improve
B (80–89)new MtpServerClientInProcessTests.
Dispose_CallbackFaultsDuringShutdown_
DoesNotThrow
2/3 killedReads server.Completion.Exception but never asserts on it, so a mutation changing the faulted exception's identity/message survives.Assert the captured exception's message/type instead of discarding it after touching .Exception.
B (80–89)new MtpServerClientInProcessTests.
LaunchInProcessAsync_HonorsTheStatefulOption
3/4 killedOnly asserts the sent initialize args; a mutation dropping the negotiated round-trip value on client.Capabilities is not caught.Also assert client.Capabilities.IsStateful reflects the negotiated round-trip value, not only the sent request.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_PassesCompleteServerModeArguments
5/5 killedVerifies exact ordered arguments, dynamic port and fixed count; strong regression guard for the generated argument array.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_DrivesInitializeDiscoverRunAndExit
6/6 killedEnd-to-end drive through initialize/discover/run/exit with distinct assertions per state transition.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackFaultsBeforeConnecting_
PreservesCallbackException
2/2 killedAsserts the exact exception instance is preserved as inner exception, not just its type.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackFaultsAsynchronouslyBeforeConnecting_
PreservesCallbackException
2/2 killedCovers the async-throw variant with the same identity assertion as the sync case.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackExitsWithoutConnecting_
FailsFastInsteadOfWaitingOutTheTimeout
3/3 killedChecks both the reported exit code in the message and a timing bound guarding against a slow-timeout regression.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackExitsBeforeConnecting_
ReportsExitCode
2/2 killedAsserts the specific exit code surfaces in the exception message.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackReturnsNullTask_Fails
2/2 killedAsserts the specific inner exception type for a misbehaving callback returning a null task.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_NullCallback_Throws
1/1 killedFocused single-assertion guard-clause test using the exact exception type.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_AlreadyCanceled_
DoesNotInvokeCallback
2/2 killedVerifies both the cancellation exception and, via an interlocked counter, that the callback never ran.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CanceledWhileConnecting_
CancelsTheCallbackToken
2/2 killedConfirms both the caller-side cancellation and that the callback's own token observed cancellation.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_ConnectionTimeoutElapses_
FailsWithTimeoutMessage
3/3 killedDistinguishes the connection timeout from the deliberately huge shutdown timeout via message and elapsed-time bound.
A (90–100)new MtpServerClientInProcessTests.
Dispose_ClosesTransportAndAwaitsTheCallback
4/4 killedChecks pre/post completion state, the callback's returned exit code, and the client's reported exit code.
A (90–100)new MtpServerClientInProcessTests.
ShutdownAsync_ClosesTransportAndAwaitsTheCallback_
WithoutBlocking
4/4 killedVerifies await-completion, exit code, a fast follow-up Dispose, and single-teardown count together.
A (90–100)new MtpServerClientInProcessTests.
Dispose_FromANotificationHandler_
DoesNotSelfWaitOnTheReadLoop
1/1 killedTargeted timing assertion guards against a specific re-entrancy deadlock regression.
A (90–100)new MtpServerClientInProcessTests.
Dispose_WhileShutdownAsyncIsInFlight_
WaitsForTheSameTeardown
3/3 killedUses a controlled release gate to prove the racing Dispose genuinely blocks, then confirms a single shared teardown.
A (90–100)new MtpServerClientInProcessTests.
Dispose_IsIdempotent
3/3 killedConfirms exactly one transport close and one completion despite three Dispose calls, plus a fast-return bound.
A (90–100)new MtpServerClientInProcessTests.
Dispose_CallbackIgnoresShutdown_
ReturnsWithinTheDocumentedBound
2/2 killedVerifies both the documented abandonment time bound and the logged "abandoning" diagnostic.
A (90–100)new MtpServerClientInProcessTests.
RunTestsAsync_Canceled_
SendsCancelRequestToTheHostedApplication
2/2 killedConfirms both the client-side cancellation exception and the wire-level cancel notification reaching the fake server.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_MultipleRequestsOnOneSession_
ReuseTheSameConnection
3/3 killedChecks keep-alive negotiation, single-connection reuse count, and total request count together.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_IgnoresEnvironmentVariablesAndWarns
1/1 killedAsserts the specific warning content naming the ignored option.
A (90–100)new MtpServerClientInProcessAcceptanceTests.
InProcessHost_
DiscoversAndRunsItsOwnMSTestNodes_
WithoutStartingAProcess
5/5 killedReal end-to-end embedded-host run asserting build success, exit code, and each marker line the generated app emits.
A (90–100)mod MtpServerClientAcceptanceTests.
DiscoverAndRun_ViaSourcePackageClient_
ReportsExpectedTestNode
1/1 killedNew lines assert the external-process ShutdownAsync/Dispose share a teardown and report an exit code.
A (90–100)mod MtpServerClientSourcePackageConsumerTests.
HostileConsumer_
CompilesAgainstPackedSource
N/ACompile-only guard; added lines correctly extend surface coverage to ShutdownAsync and the in-process launch path.

This advisory comment was generated automatically. Grades are heuristic
and informational — they do not block merging. Suggestions on the Files
changed tab can be applied with one click. Re-run with
/review-tests.

🤖 Automated content by GitHub Copilot. Generated by the Test Reviewer on PR (on open / sync) workflow. · auto · 159.6 AIC · ⌖ 2.87 AIC · ⊞ 16.9K · [◷]( · )

@github-actions

Copy link
Copy Markdown
Contributor

🧵 Parallel-safety audit — PR #10898

Parallelization — audited assemblies:

Test assemblyScopeWorkersAnalyzer coverage
MSTest.Acceptance.IntegrationTestsMethodLevel (default [assembly: Parallelize(Scope = ExecutionScope.MethodLevel, Workers = 0)] in Program.cs)CPU countcoverable once the parallel-safety analyzers ship (attribute-based opt-in)
Microsoft.Testing.Platform.Acceptance.IntegrationTestsMethodLevel (Program.cs)CPU countcoverable once shipped
Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTestsMethodLevel (Program.cs)CPU countcoverable once shipped

This PR did not touch any .runsettings/testconfig.json/Directory.Build.* parallelization config — no assembly's scope changed.

Findings: A (global-state) 0 · B (paths) 0 · C (declaration) 0 · D (over-serialization) 0 — by severity: Critical 0 · High 0 · Warning 0 · Info 0.

No unsafe call sites found in the lines this PR added or modified.

  • MtpServerClientAcceptanceTests.cs — added lines only call client.ShutdownAsync() / assert ServerExitCode; no process-global state touched.
  • MtpServerClientSourcePackageConsumerTests.cs — added lines call into an isolated generated test asset (DriveAsync/DriveInProcessAsync); no shared/relative paths, env vars, culture, or console state mutated at the assembly level (Console.WriteLine here writes to the generated app's own redirected output, not the auditing process).
  • MtpServerClientInProcessAcceptanceTests.cs (new) — uses TestAsset.GenerateAssetAsync, which allocates a unique GUID-suffixed TempDirectory per instance (TestAsset.cs:19-26, throws if a path already exists) — no shared-path collision. CreateChildEnvironment() builds a fresh Dictionary passed only to the spawned child process's environment, not Environment.SetEnvironmentVariable, so it does not mutate this process's state.
  • FakeMtpServer.cs / MtpServerClientInProcessTests.cs (new) — each test constructs its own TcpListener/FakeMtpServer bound to an OS-assigned loopback port (new TcpListener(IPAddress.Loopback, 0)), so there is no fixed-port collision between concurrently-running tests; no static/process-global state is written.

Nothing here implies a testability or smell concern beyond this audit's scope.

Advisory only — heuristic, non-blocking. Re-run with /parallel-audit. This audit answers "is it parallel-safe?"; for testability, smells, or flakiness see the detect-static-dependencies / test-smell-detection / test-anti-patterns analyses.

🤖 Automated content by GitHub Copilot. Generated by the Parallel-safety audit on PR (on open / sync) workflow. · auto · 117.1 AIC · ⌖ 3.53 AIC · ⊞ 24.8K · [◷]( · )

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated content by GitHub Copilot. Generated by the Test Reviewer on PR (on open / sync) workflow. · auto · 159.6 AIC · ⌖ 2.87 AIC · ⊞ 16.9K ·

client.Dispose();

Assert.IsTrue(server.Completion.IsFaulted);
_ = server.Completion.Exception;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test review · Grade B (80–89) — Reads server.Completion.Exception but never asserts on it, so a mutation changing the faulted exception's identity/message would survive.

Assert the captured exception's message/type instead of discarding it after touching .Exception.

Suggested change
_=server.Completion.Exception;
Exception?faultException=server.Completion.Exception?.GetBaseException();
Assert.IsInstanceOfType<InvalidOperationException>(faultException);
Assert.AreEqual("The application failed while shutting down.",faultException!.Message);

? typed
: SerializerUtilities.Deserialize<InitializeRequestArgs>((IDictionary<string, object?>)initialize.Params!);

Assert.AreEqual(isStateful, args.Capabilities.IsStateful, "The in-process path must forward the client's stateful capability unchanged.");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test review · Grade B (80–89) — Only asserts the sent initialize args; a mutation dropping the negotiated round-trip value on client.Capabilities is not caught.

Also assert client.Capabilities.IsStateful reflects the negotiated round-trip value, not only the sent request.

Suggested change
Assert.AreEqual(isStateful,args.Capabilities.IsStateful,"The in-process path must forward the client's stateful capability unchanged.");
Assert.AreEqual(isStateful,args.Capabilities.IsStateful,"The in-process path must forward the client's stateful capability unchanged.");
Assert.AreEqual(isStateful,capabilities.IsStateful,"The negotiated capability the client stores must reflect the server's round trip, not just the sent request.");

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

🤖 Automated review by GitHub Copilot. Generated by the Expert Code Review workflow. To request a follow-up action, reply by tagging @copilot directly.

✅ 22/22 dimensions clean — no findings.

Summary: This PR adds MtpServerClient.LaunchInProcessAsync for embedded hosts (MAUI, Android/iOS) that cannot Process.Start. The implementation is thorough and well-structured:

  • Threading & Concurrency: Shutdown is idempotent via lock + lazy Task, Volatile.Read/Write for cross-thread exit code, Task.Run avoids sync-context deadlocks. No shared mutable state without synchronization.
  • Security & IPC: Loopback TCP only, no command-line injection surface (arguments are array-based), WASM fails fast with PlatformNotSupportedException.
  • Public API: All new API is internal (source-only package). No init accessors. No PublicAPI.Unshipped.txt changes needed.
  • Performance: GetCurrentProcessId() snapshotted once in constructor. No hot-path allocations. WaitBoundedAsync clamps timeouts correctly.
  • Cross-TFM: #if NET8_0_OR_GREATER guard on AcceptTcpClientAsync(CancellationToken). RuntimeInformation used for OS detection on net462.
  • Resource Management: Every disposable (TcpListener, TcpClient, CancellationTokenSource, Process) has cleanup in both success and error paths. Pending accepts are neutralized. ServerCancellation is only disposed when safe.
  • Defensive Coding: Callback exceptions are wrapped as InnerException in MtpServerConnectionClosedException. Null task from callback is caught. Cancel() runs on thread pool to avoid blocking registration.
  • Error Handling: Shared teardown task wrapped in catch-all so it never faults. ObserveFailure prevents UnobservedTaskException. Every teardown helper is non-throwing.
  • Tests: 68 unit tests + acceptance tests covering argument shape, lifecycle, faults, cancellation, timeouts, idempotent disposal, notification-handler disposal, and the real end-to-end in-process path.
  • Documentation: Changelog, protocol intro, PACKAGE.md all updated. XML doc comments are thorough.

The refactoring of shared transport logic into MtpServerConnector and the IMtpServerHost abstraction is clean and prevents the two launch paths from drifting. The existing LaunchAsync(path) path is provably unchanged (same argument string, same failure messages, same teardown order).

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

Review tier: Balanced
Findings: 3 Medium severity

New issues introduced by this change (3)
SeverityFinding
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.csMtpJsonRpcConnection.Dispose() closes the socket and then waits up to its 5-second read-loop…
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.csShutdownAsync writes this nullable property from the teardown worker while callers can read…
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.cs — A canceled callback takes this branch without an inner exception, although the package…
What changed in this PR

Adds in-process launch support to the source-only MTP server-mode client for embedded/mobile hosts, addressing #10890 and enabling scenarios related to #9809.

Changes:

  • Adds callback-based in-process hosting with shared transport setup.
  • Adds bounded asynchronous shutdown and server exit-code reporting.
  • Adds documentation, unit coverage, and real-MSTest acceptance coverage.
FileDescription
MtpServerClientInProcessTests.csTests in-process lifecycle and protocol behavior.
FakeMtpServer.csSupports server-to-client dial-back mode.
MtpServerClientInProcessAcceptanceTests.csExercises a real in-process MSTest application.
MtpServerClientAcceptanceTests.csCovers external-process shutdown and exit code.
MtpServerClientSourcePackageConsumerTests.csExtends source-package compile coverage.
PACKAGE.mdDocuments embedded-host usage and lifecycle.
MtpServerProcess.csAdopts shared host and shutdown abstractions.
MtpServerInProcessHost.csImplements callback hosting and teardown.
MtpServerConnector.csCentralizes listener and transport setup.
MtpServerClientOptions.csAdds the shutdown timeout option.
MtpServerClientExceptions.csSupports preserved inner exceptions.
MtpServerClient.csExposes in-process launch and shutdown.
IMtpServerHost.csDefines common host ownership behavior.
IMtpServerClient.csAdds shutdown and exit-code members.
001-protocol-intro.mdDocuments reference-client launch modes.
Changelog-Platform.mdRecords the new embedded-host capability.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +254 to +258
Connection.Dispose();
SafeDispose(_client, _logger, "Disposing the accepted client socket");
MtpServerConnector.SafeStop(_listener, _logger);

bool stopped = await ShutdownServerAsync(_serverTask, _serverCancellation, _shutdownTimeout, _logger).ConfigureAwait(false);
/// Gets the exit code the hosted application returned, or <see langword="null"/> while it is still
/// running (or when it failed or was abandoned rather than returning one).
/// </summary>
public int? ExitCode { get; private set; }
Comment on lines +311 to +315
if (serverTask.IsCanceled)
{
return new MtpServerConnectionClosedException(
"The in-process Microsoft.Testing.Platform application was canceled before connecting back.");
}
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add in-process launch support to the MTP server-mode client

2 participants

@Evangelink
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add in-process launch support to the MTP server-mode client by Evangelink · Pull Request #10898 · microsoft/testfx · GitHub
Skip to content

Add in-process launch support to the MTP server-mode client - #10898

Open
Amaury Levé (Evangelink) wants to merge 4 commits into
mainfrom
dev/amauryleve/mtp-in-process-client-launch
Open

Add in-process launch support to the MTP server-mode client#10898
Amaury Levé (Evangelink) wants to merge 4 commits into
mainfrom
dev/amauryleve/mtp-in-process-client-launch

Conversation

@Evangelink

Copy link
Copy Markdown
Member

Fixes#10890

Why

MtpServerClient.LaunchAsync(path) owns the loopback listener and starts the test application with Process.Start. That is right for IDE and desktop tooling, but unusable for embedded hosts — MAUI apps, Android/iOS test apps — where the MTP application already runs in the caller's process.

Today those hosts have to reimplement the listener, the --server/host/port arguments, the race between connection and startup failure, the serializer-before-formatter registration order, TcpMessageHandler + MtpJsonRpcConnection construction, and the exit / transport-close / server-completion shutdown dance. That defeats the point of shipping a canonical client and makes it easy to reintroduce bugs this package already solved: partial frame writes, ignored JSON-RPC errors, missing $/cancelRequest, unbounded waits, exception masking.

A concrete consumer is the DeviceRunners MSTest visual-runner work (mattleibow/DeviceRunners#157, #9809).

What

usingIMtpServerClientclient=awaitMtpServerClient.LaunchInProcessAsync(async(serverArgs,token)=>{ITestApplicationBuilderbuilder=awaitTestApplication.CreateBuilderAsync(serverArgs);builder.AddMSTest(()=>testAssemblies);usingITestApplicationapp=awaitbuilder.BuildAsync();returnawaitapp.RunAsync();},options,cancellationToken);awaitclient.InitializeAsync();awaitclient.DiscoverTestsAsync();awaitclient.RunTestsAsync();awaitclient.ExitAsync();awaitclient.ShutdownAsync();

The caller supplies only "how to run the application". The client keeps everything else: it binds the listener, generates the complete argument array (--server jsonrpc --client-host 127.0.0.1 --client-port <port> --no-banner), races the connect, builds the transport, and owns a bounded shutdown.

New API on the injected (still internal) surface:

MemberPurpose
MtpServerClient.LaunchInProcessAsync(callback, options, ct)The embedded-host launch path. Async only — blocking the launching thread can deadlock the application being launched.
IMtpServerClient.ShutdownAsync()Non-blocking teardown.
IMtpServerClient.ServerExitCodeThe value the application returned.
MtpServerClientOptions.ServerShutdownTimeoutGraceful shutdown bound (default 30s).

Internally, the transport setup was extracted from MtpServerProcess into MtpServerConnector, and both launch paths now sit behind IMtpServerHost, so they cannot drift.

Behavior worth reviewing

Ownership and shutdown. Both Dispose() and ShutdownAsync() join one lazily created shared teardown task, on both hosts. A Dispose that follows or races ShutdownAsync therefore returns only once the server has actually stopped, rather than reporting success while teardown is still running. Teardown runs on the thread pool, so ShutdownAsync never blocks and the synchronous Dispose cannot deadlock against a UI-thread continuation. Task.Run captures the execution context, so the connection's read-loop AsyncLocal marker still flows and disposing from a notification handler does not self-wait (covered by a test asserting < 4s against the connection's 5s read-loop timeout).

Bounded, and actually bounded. Teardown waits ServerShutdownTimeout, then cancels the callback's token, then a fixed 5s grace, then abandons and logs. CancellationTokenSource.Cancel() runs registrations synchronously, so the cancellation is started separately — otherwise a blocking caller registration would prevent the grace from ever starting and make the "bounded" wait unbounded. A failed launch skips the graceful wait entirely: nothing is connected, so there is no transport closure for the callback to observe.

Exception preservation. A callback that throws, is canceled, or returns before dialing back surfaces as MtpServerConnectionClosedException with the original as InnerException, instead of a misleading connection timeout. Every teardown helper is non-throwing, and the shared teardown task is wrapped so it can never fault — a faulted shared task would throw from every later disposal.

LaunchAsync(path) is unchanged. Verified against main: identical argument string, failure messages, stderr capture, exit fast-fail and teardown order. One earlier revision of this branch added a grace period to the accept race; review showed it only traded a precise failure for a vague one, so it was removed and the external path is now provably unchanged.

Browser/WASM. Both paths are loopback TCP. LaunchInProcessAsync fails fast with PlatformNotSupportedException there; this does not enable WASM hosting, and the docs say so.

Tests

  • 68 unit tests (net8.0) / 63 (net462, Jsonite path). New coverage: argument array shape; initialize → discover → run → exit; callback faulting synchronously and asynchronously; callback exiting with a code; null task; pre-canceled launch not invoking the callback; cancellation during connect canceling the callback token; connection timeout bounded by the grace rather than ServerShutdownTimeout; ShutdownAsync; ServerExitCode; disposal awaiting the callback; Dispose racing an in-flight ShutdownAsync; Dispose from a notification handler; idempotent disposal; callback faulting during shutdown; unresponsive callback abandoned within the bound; $/cancelRequest; stateful on/off; multi-request single connection; EnvironmentVariables ignored and warned. Stressed 6×/5× consecutively for flakiness.
  • New acceptance test where a single generated process is simultaneously the embedded host (compiling the packed source-only package) and a real MTP TestApplication with real MSTest over a real [TestClass] — discovering and running its own test over JSON-RPC with no Process.Start anywhere. It also asserts the server's reported process id equals its own.
  • Existing external-process acceptance test extended to cover ShutdownAsync + ServerExitCode; hostile-consumer compile oracle extended to bind the new API on net462/netstandard2.0/net5.0–net8.0.
  • Full regression: 13 platform source-package/consumer acceptance, 4 MSTest acceptance, 13 platform ServerTests. build.cmd -pack clean, 0 warnings.

Review history

Four independent review rounds (MTP/MSTest expert reviewer, a design reviewer, and two correctness reviewers) produced 3 major, 10 moderate and 10 minor findings, all addressed. Several were real bugs the tests then locked in — notably Process.ExitCode being unreadable after Process.Dispose(), a TcpListener socket leak when Start() failed, and a teardown path that could throw from a contract documented never to throw.

Open design questions

  1. No IAsyncDisposable. netstandard2.0/net462 would need Microsoft.Bcl.AsyncInterfaces, breaking the package's dependency-free promise. ShutdownAsync() is the substitute. Happy to revisit if the dependency is acceptable.
  2. MtpServerClientOptions is mode-mixed.EnvironmentVariables is external-process only; ServerShutdownTimeout is in-process only. Nesting per-transport options would age better, but EnvironmentVariables already shipped at the top level, so it cannot be done non-breakingly now.
  3. The 5s cancellation grace is a fixed constant, not an option.
  4. The two paths still build different argument shapes — deliberate, to keep the shipped external command line byte-identical. Commented at the call site.
  5. No ConnectAsync(TcpClient/Stream, options) factory — the existing MtpServerClient(MtpJsonRpcConnection, options) constructor already covers "wrap an existing transport", and the guidance is to minimize injected surface. Easy to add if wanted.

Embedded hosts such as MAUI or Android/iOS test apps cannot spawn a child
process, so `MtpServerClient.LaunchAsync(path)` was unusable for them and they
had to reimplement the listener, the server-mode arguments, the connect race,
the serializer/formatter ordering, the transport and the shutdown coordination
by hand -- reintroducing the bugs the canonical client already solves.
Add `MtpServerClient.LaunchInProcessAsync(callback, options, cancellationToken)`.
The client keeps ownership of everything except "how to run the application":
it binds the loopback listener, generates the complete server-mode argument
array, races the connect against callback failure/completion, caller
cancellation and the connection timeout, and owns a bounded shutdown that
closes the transport, then cancels the callback token, then abandons it rather
than hanging the caller. A callback failure before connection is surfaced as
`MtpServerConnectionClosedException` with the caller's exception preserved as
the inner exception, and teardown failures are only logged so they can never
replace the primary failure.
The shared transport setup is extracted from `MtpServerProcess` into
`MtpServerConnector` and both launch paths now flow through `IMtpServerHost`,
so the external-process behavior is unchanged while the two paths cannot drift.
The one behavior change is an improvement shared by both: when the server is
seen to have stopped, a still-pending accept gets a bounded final grace so a
connection established just before the stop is not discarded in favor of a
misleading "stopped before connecting back" failure.
The path is loopback TCP, so it fails fast with `PlatformNotSupportedException`
on browser/WASM; it does not enable WASM hosting.
Fixes#10890
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two independent reviews of the in-process launch path raised the same top
finding: `Dispose()` waits for the hosted application synchronously on the
calling thread, and on the very platforms this feature targets (MAUI, Android,
iOS) a multi-second block on the UI thread trips the ANR / watchdog. Add
`IMtpServerClient.ShutdownAsync()` (and `IMtpServerHost.ShutdownAsync`) so the
same teardown can be awaited instead. `IAsyncDisposable` stays rejected --
netstandard2.0 and net462 would need `Microsoft.Bcl.AsyncInterfaces`, which
breaks the package's dependency-free promise -- but rejecting the interface is
not a reason to have no asynchronous shutdown at all. `Dispose()` remains, is
still idempotent, and returns immediately after `ShutdownAsync`.
Remove the accept grace period. The previous commit let a still-pending accept
win for ~100ms after the server was seen to have stopped, on the theory that a
connection established just before the stop should not be discarded. That
theory does not hold: if the server has stopped, the socket belongs to a dead
peer, so the grace only traded a precise failure (exit code plus captured
stderr) for a generic connection-closed error on the first request, and its
uncancellable delay could let the server failure beat a concurrent caller
cancellation. Dropping it also makes the external-process path provably
unchanged, so the changelog no longer needs a `Changed` entry.
Other review fixes:
* Expose `IMtpServerClient.ServerExitCode`. The callback signature already
demanded a `Task<int>`, but after a successful session the value was
unreachable, so an embedded host whose `Main` must return it had to capture
it in a closure.
* Do not dispose the server's `CancellationTokenSource` when the callback was
abandoned while still running: it holds the token, and `token.WaitHandle` or
`CreateLinkedTokenSource` would then throw `ObjectDisposedException` inside
the caller's own code.
* Skip the graceful wait entirely on a failed launch. Nothing is connected, so
there is no transport closure for the callback to observe; only the fixed
cancellation grace applies and an unwinding caller no longer pays
`ServerShutdownTimeout`.
* Clamp bounded waits instead of trusting the caller's `TimeSpan`. A negative
value (or `Timeout.InfiniteTimeSpan`) made `Task.Delay` throw from a path
documented never to throw; an oversized one is capped to the largest delay
.NET Framework accepts.
* Move `TcpListener.Start()` inside the cleanup `try`: it creates the socket
before binding, so a bind failure leaked it because the caller never received
a listener it could stop.
* Log a late failure from an abandoned callback rather than only observing it.
* Cache the in-process host's `ProcessId` instead of allocating a `Process` per
property read.
* Document that `Dispose()` blocks, that cancellation is bounded rather than
immediate, and why the two launch paths still build different argument
shapes; fix the shadowed `cancellationToken` in the PACKAGE.md sample.
Tests: cover `ShutdownAsync`, `ServerExitCode`, and fail-fast on a server that
stops without connecting; make `Dispose_IsIdempotent` able to fail by asserting
on observed transport closes rather than callback invocations; replace the
hand-rolled throws helper with `Assert.ThrowsExactlyAsync`; tighten the
shutdown-bound assertion; and stop leaking `Process` handles.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A third review round found three ways the teardown contract did not hold up
under concurrency.
`Dispose()` and `ShutdownAsync()` each had their own `Interlocked.Exchange`
guard, so whichever call lost the race returned while teardown was still
running -- reporting to its caller that the application had stopped when it had
not. Replace both guards with one lazily started, shared teardown task: every
entry point now joins the same task, so `Dispose` after (or racing)
`ShutdownAsync` returns only once the application has actually stopped, and the
whole thing stays idempotent. `MtpServerClient` no longer keeps its own guard
either; it delegates to the host, whose teardown is the single source of truth.
`ShutdownAsync()` could still block the very thread it exists to protect: it
closed the transport before its first await, and closing the connection waits
up to five seconds for the read loop. The shared task is started with
`Task.Run`, so the whole teardown -- transport close included -- runs on the
thread pool and `ShutdownAsync` returns immediately. `MtpServerProcess`
likewise now runs its (bounded but synchronous) kill off the calling thread
rather than pretending to be async while blocking.
`CancellationTokenSource.Cancel()` executes registrations synchronously on the
calling thread, so a caller registration that blocked would prevent the
five-second cancellation grace from ever starting and make the "bounded"
shutdown unbounded. Start the cancellation separately and begin the grace
regardless. When a registration is still executing once the callback has
finished, the source is reported as unsafe to dispose for the same reason an
abandoned callback is: leaking one `CancellationTokenSource` beats a
use-after-dispose inside the caller's code.
Adds a test that a `Dispose` racing an in-flight `ShutdownAsync` blocks until
the shared teardown completes, and that both share one teardown.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… fix ExitCode
A fourth review round found that only the in-process host had been converted to
the shared-teardown design. `MtpServerProcess` kept its old early-return guard,
so on the external-process path a `Dispose()` that followed or raced
`ShutdownAsync()` returned while `SafeKill`'s bounded `WaitForExit` was still
running on another thread -- losing exactly the guarantee that wait exists for
(a caller may delete the application directory immediately after disposal), and
making `await ShutdownAsync()` report that the server had stopped when it had
not. That contradicted the comment the previous commit added to
`MtpServerClient.Dispose`, which claimed both paths joined an in-flight
teardown. Convert `MtpServerProcess` to the same lazily created shared task, so
the contract is now uniform across both implementations of `IMtpServerHost`,
and reconcile the interface docs, which previously described the opposite rule.
Adding real coverage for `ShutdownAsync` on the external-process path then
surfaced a genuine bug: `MtpServerProcess.ExitCode` was always `null` after
teardown, because a `Process` cannot be queried once disposed. Capture the exit
code during teardown instead -- before the kill, so an application that already
exited on its own reports its real code rather than the kill's, and before
`Process.Dispose()`, after which nothing is readable.
Also fixes a race in the in-process test fixture that made the suite flaky on
net462: the callback published its `FakeMtpServer` only after its own connect
call returned, but the client's accept can complete first, so a test could
reach `Value` before the callback had set it. The fixture now exposes a
`Connected` signal the launch helper awaits. 6/6 clean net462 runs and 5/5
clean net8.0 runs afterwards.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI balanced review requested due to automatic review settings August 31, 2026 19:22
Comment on lines +182 to +193
if (serverTask is not null)
{
// 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.
if (!await ShutdownServerAsync(serverTask, serverCancellation!, TimeSpan.Zero, logger).ConfigureAwait(false))
{
// 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;
}
}
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Expert test review — PR #10898

GradeTestMutationNotesHow to improve
B (80–89)new MtpServerClientInProcessTests.
Dispose_CallbackFaultsDuringShutdown_
DoesNotThrow
2/3 killedReads server.Completion.Exception but never asserts on it, so a mutation changing the faulted exception's identity/message survives.Assert the captured exception's message/type instead of discarding it after touching .Exception.
B (80–89)new MtpServerClientInProcessTests.
LaunchInProcessAsync_HonorsTheStatefulOption
3/4 killedOnly asserts the sent initialize args; a mutation dropping the negotiated round-trip value on client.Capabilities is not caught.Also assert client.Capabilities.IsStateful reflects the negotiated round-trip value, not only the sent request.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_PassesCompleteServerModeArguments
5/5 killedVerifies exact ordered arguments, dynamic port and fixed count; strong regression guard for the generated argument array.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_DrivesInitializeDiscoverRunAndExit
6/6 killedEnd-to-end drive through initialize/discover/run/exit with distinct assertions per state transition.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackFaultsBeforeConnecting_
PreservesCallbackException
2/2 killedAsserts the exact exception instance is preserved as inner exception, not just its type.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackFaultsAsynchronouslyBeforeConnecting_
PreservesCallbackException
2/2 killedCovers the async-throw variant with the same identity assertion as the sync case.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackExitsWithoutConnecting_
FailsFastInsteadOfWaitingOutTheTimeout
3/3 killedChecks both the reported exit code in the message and a timing bound guarding against a slow-timeout regression.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackExitsBeforeConnecting_
ReportsExitCode
2/2 killedAsserts the specific exit code surfaces in the exception message.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackReturnsNullTask_Fails
2/2 killedAsserts the specific inner exception type for a misbehaving callback returning a null task.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_NullCallback_Throws
1/1 killedFocused single-assertion guard-clause test using the exact exception type.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_AlreadyCanceled_
DoesNotInvokeCallback
2/2 killedVerifies both the cancellation exception and, via an interlocked counter, that the callback never ran.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CanceledWhileConnecting_
CancelsTheCallbackToken
2/2 killedConfirms both the caller-side cancellation and that the callback's own token observed cancellation.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_ConnectionTimeoutElapses_
FailsWithTimeoutMessage
3/3 killedDistinguishes the connection timeout from the deliberately huge shutdown timeout via message and elapsed-time bound.
A (90–100)new MtpServerClientInProcessTests.
Dispose_ClosesTransportAndAwaitsTheCallback
4/4 killedChecks pre/post completion state, the callback's returned exit code, and the client's reported exit code.
A (90–100)new MtpServerClientInProcessTests.
ShutdownAsync_ClosesTransportAndAwaitsTheCallback_
WithoutBlocking
4/4 killedVerifies await-completion, exit code, a fast follow-up Dispose, and single-teardown count together.
A (90–100)new MtpServerClientInProcessTests.
Dispose_FromANotificationHandler_
DoesNotSelfWaitOnTheReadLoop
1/1 killedTargeted timing assertion guards against a specific re-entrancy deadlock regression.
A (90–100)new MtpServerClientInProcessTests.
Dispose_WhileShutdownAsyncIsInFlight_
WaitsForTheSameTeardown
3/3 killedUses a controlled release gate to prove the racing Dispose genuinely blocks, then confirms a single shared teardown.
A (90–100)new MtpServerClientInProcessTests.
Dispose_IsIdempotent
3/3 killedConfirms exactly one transport close and one completion despite three Dispose calls, plus a fast-return bound.
A (90–100)new MtpServerClientInProcessTests.
Dispose_CallbackIgnoresShutdown_
ReturnsWithinTheDocumentedBound
2/2 killedVerifies both the documented abandonment time bound and the logged "abandoning" diagnostic.
A (90–100)new MtpServerClientInProcessTests.
RunTestsAsync_Canceled_
SendsCancelRequestToTheHostedApplication
2/2 killedConfirms both the client-side cancellation exception and the wire-level cancel notification reaching the fake server.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_MultipleRequestsOnOneSession_
ReuseTheSameConnection
3/3 killedChecks keep-alive negotiation, single-connection reuse count, and total request count together.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_IgnoresEnvironmentVariablesAndWarns
1/1 killedAsserts the specific warning content naming the ignored option.
A (90–100)new MtpServerClientInProcessAcceptanceTests.
InProcessHost_
DiscoversAndRunsItsOwnMSTestNodes_
WithoutStartingAProcess
5/5 killedReal end-to-end embedded-host run asserting build success, exit code, and each marker line the generated app emits.
A (90–100)mod MtpServerClientAcceptanceTests.
DiscoverAndRun_ViaSourcePackageClient_
ReportsExpectedTestNode
1/1 killedNew lines assert the external-process ShutdownAsync/Dispose share a teardown and report an exit code.
A (90–100)mod MtpServerClientSourcePackageConsumerTests.
HostileConsumer_
CompilesAgainstPackedSource
N/ACompile-only guard; added lines correctly extend surface coverage to ShutdownAsync and the in-process launch path.

This advisory comment was generated automatically. Grades are heuristic
and informational — they do not block merging. Suggestions on the Files
changed tab can be applied with one click. Re-run with
/review-tests.

🤖 Automated content by GitHub Copilot. Generated by the Test Reviewer on PR (on open / sync) workflow. · auto · 159.6 AIC · ⌖ 2.87 AIC · ⊞ 16.9K · [◷]( · )

@github-actions

Copy link
Copy Markdown
Contributor

🧵 Parallel-safety audit — PR #10898

Parallelization — audited assemblies:

Test assemblyScopeWorkersAnalyzer coverage
MSTest.Acceptance.IntegrationTestsMethodLevel (default [assembly: Parallelize(Scope = ExecutionScope.MethodLevel, Workers = 0)] in Program.cs)CPU countcoverable once the parallel-safety analyzers ship (attribute-based opt-in)
Microsoft.Testing.Platform.Acceptance.IntegrationTestsMethodLevel (Program.cs)CPU countcoverable once shipped
Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTestsMethodLevel (Program.cs)CPU countcoverable once shipped

This PR did not touch any .runsettings/testconfig.json/Directory.Build.* parallelization config — no assembly's scope changed.

Findings: A (global-state) 0 · B (paths) 0 · C (declaration) 0 · D (over-serialization) 0 — by severity: Critical 0 · High 0 · Warning 0 · Info 0.

No unsafe call sites found in the lines this PR added or modified.

  • MtpServerClientAcceptanceTests.cs — added lines only call client.ShutdownAsync() / assert ServerExitCode; no process-global state touched.
  • MtpServerClientSourcePackageConsumerTests.cs — added lines call into an isolated generated test asset (DriveAsync/DriveInProcessAsync); no shared/relative paths, env vars, culture, or console state mutated at the assembly level (Console.WriteLine here writes to the generated app's own redirected output, not the auditing process).
  • MtpServerClientInProcessAcceptanceTests.cs (new) — uses TestAsset.GenerateAssetAsync, which allocates a unique GUID-suffixed TempDirectory per instance (TestAsset.cs:19-26, throws if a path already exists) — no shared-path collision. CreateChildEnvironment() builds a fresh Dictionary passed only to the spawned child process's environment, not Environment.SetEnvironmentVariable, so it does not mutate this process's state.
  • FakeMtpServer.cs / MtpServerClientInProcessTests.cs (new) — each test constructs its own TcpListener/FakeMtpServer bound to an OS-assigned loopback port (new TcpListener(IPAddress.Loopback, 0)), so there is no fixed-port collision between concurrently-running tests; no static/process-global state is written.

Nothing here implies a testability or smell concern beyond this audit's scope.

Advisory only — heuristic, non-blocking. Re-run with /parallel-audit. This audit answers "is it parallel-safe?"; for testability, smells, or flakiness see the detect-static-dependencies / test-smell-detection / test-anti-patterns analyses.

🤖 Automated content by GitHub Copilot. Generated by the Parallel-safety audit on PR (on open / sync) workflow. · auto · 117.1 AIC · ⌖ 3.53 AIC · ⊞ 24.8K · [◷]( · )

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated content by GitHub Copilot. Generated by the Test Reviewer on PR (on open / sync) workflow. · auto · 159.6 AIC · ⌖ 2.87 AIC · ⊞ 16.9K ·

client.Dispose();

Assert.IsTrue(server.Completion.IsFaulted);
_ = server.Completion.Exception;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test review · Grade B (80–89) — Reads server.Completion.Exception but never asserts on it, so a mutation changing the faulted exception's identity/message would survive.

Assert the captured exception's message/type instead of discarding it after touching .Exception.

Suggested change
_=server.Completion.Exception;
Exception?faultException=server.Completion.Exception?.GetBaseException();
Assert.IsInstanceOfType<InvalidOperationException>(faultException);
Assert.AreEqual("The application failed while shutting down.",faultException!.Message);

? typed
: SerializerUtilities.Deserialize<InitializeRequestArgs>((IDictionary<string, object?>)initialize.Params!);

Assert.AreEqual(isStateful, args.Capabilities.IsStateful, "The in-process path must forward the client's stateful capability unchanged.");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test review · Grade B (80–89) — Only asserts the sent initialize args; a mutation dropping the negotiated round-trip value on client.Capabilities is not caught.

Also assert client.Capabilities.IsStateful reflects the negotiated round-trip value, not only the sent request.

Suggested change
Assert.AreEqual(isStateful,args.Capabilities.IsStateful,"The in-process path must forward the client's stateful capability unchanged.");
Assert.AreEqual(isStateful,args.Capabilities.IsStateful,"The in-process path must forward the client's stateful capability unchanged.");
Assert.AreEqual(isStateful,capabilities.IsStateful,"The negotiated capability the client stores must reflect the server's round trip, not just the sent request.");

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

🤖 Automated review by GitHub Copilot. Generated by the Expert Code Review workflow. To request a follow-up action, reply by tagging @copilot directly.

✅ 22/22 dimensions clean — no findings.

Summary: This PR adds MtpServerClient.LaunchInProcessAsync for embedded hosts (MAUI, Android/iOS) that cannot Process.Start. The implementation is thorough and well-structured:

  • Threading & Concurrency: Shutdown is idempotent via lock + lazy Task, Volatile.Read/Write for cross-thread exit code, Task.Run avoids sync-context deadlocks. No shared mutable state without synchronization.
  • Security & IPC: Loopback TCP only, no command-line injection surface (arguments are array-based), WASM fails fast with PlatformNotSupportedException.
  • Public API: All new API is internal (source-only package). No init accessors. No PublicAPI.Unshipped.txt changes needed.
  • Performance: GetCurrentProcessId() snapshotted once in constructor. No hot-path allocations. WaitBoundedAsync clamps timeouts correctly.
  • Cross-TFM: #if NET8_0_OR_GREATER guard on AcceptTcpClientAsync(CancellationToken). RuntimeInformation used for OS detection on net462.
  • Resource Management: Every disposable (TcpListener, TcpClient, CancellationTokenSource, Process) has cleanup in both success and error paths. Pending accepts are neutralized. ServerCancellation is only disposed when safe.
  • Defensive Coding: Callback exceptions are wrapped as InnerException in MtpServerConnectionClosedException. Null task from callback is caught. Cancel() runs on thread pool to avoid blocking registration.
  • Error Handling: Shared teardown task wrapped in catch-all so it never faults. ObserveFailure prevents UnobservedTaskException. Every teardown helper is non-throwing.
  • Tests: 68 unit tests + acceptance tests covering argument shape, lifecycle, faults, cancellation, timeouts, idempotent disposal, notification-handler disposal, and the real end-to-end in-process path.
  • Documentation: Changelog, protocol intro, PACKAGE.md all updated. XML doc comments are thorough.

The refactoring of shared transport logic into MtpServerConnector and the IMtpServerHost abstraction is clean and prevents the two launch paths from drifting. The existing LaunchAsync(path) path is provably unchanged (same argument string, same failure messages, same teardown order).

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

Review tier: Balanced
Findings: 3 Medium severity

New issues introduced by this change (3)
SeverityFinding
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.csMtpJsonRpcConnection.Dispose() closes the socket and then waits up to its 5-second read-loop…
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.csShutdownAsync writes this nullable property from the teardown worker while callers can read…
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.cs — A canceled callback takes this branch without an inner exception, although the package…
What changed in this PR

Adds in-process launch support to the source-only MTP server-mode client for embedded/mobile hosts, addressing #10890 and enabling scenarios related to #9809.

Changes:

  • Adds callback-based in-process hosting with shared transport setup.
  • Adds bounded asynchronous shutdown and server exit-code reporting.
  • Adds documentation, unit coverage, and real-MSTest acceptance coverage.
FileDescription
MtpServerClientInProcessTests.csTests in-process lifecycle and protocol behavior.
FakeMtpServer.csSupports server-to-client dial-back mode.
MtpServerClientInProcessAcceptanceTests.csExercises a real in-process MSTest application.
MtpServerClientAcceptanceTests.csCovers external-process shutdown and exit code.
MtpServerClientSourcePackageConsumerTests.csExtends source-package compile coverage.
PACKAGE.mdDocuments embedded-host usage and lifecycle.
MtpServerProcess.csAdopts shared host and shutdown abstractions.
MtpServerInProcessHost.csImplements callback hosting and teardown.
MtpServerConnector.csCentralizes listener and transport setup.
MtpServerClientOptions.csAdds the shutdown timeout option.
MtpServerClientExceptions.csSupports preserved inner exceptions.
MtpServerClient.csExposes in-process launch and shutdown.
IMtpServerHost.csDefines common host ownership behavior.
IMtpServerClient.csAdds shutdown and exit-code members.
001-protocol-intro.mdDocuments reference-client launch modes.
Changelog-Platform.mdRecords the new embedded-host capability.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +254 to +258
Connection.Dispose();
SafeDispose(_client, _logger, "Disposing the accepted client socket");
MtpServerConnector.SafeStop(_listener, _logger);

bool stopped = await ShutdownServerAsync(_serverTask, _serverCancellation, _shutdownTimeout, _logger).ConfigureAwait(false);
/// Gets the exit code the hosted application returned, or <see langword="null"/> while it is still
/// running (or when it failed or was abandoned rather than returning one).
/// </summary>
public int? ExitCode { get; private set; }
Comment on lines +311 to +315
if (serverTask.IsCanceled)
{
return new MtpServerConnectionClosedException(
"The in-process Microsoft.Testing.Platform application was canceled before connecting back.");
}
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add in-process launch support to the MTP server-mode client

2 participants

@Evangelink
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Add in-process launch support to the MTP server-mode client by Evangelink · Pull Request #10898 · microsoft/testfx · GitHub
Skip to content

Add in-process launch support to the MTP server-mode client - #10898

Open
Amaury Levé (Evangelink) wants to merge 4 commits into
mainfrom
dev/amauryleve/mtp-in-process-client-launch
Open

Add in-process launch support to the MTP server-mode client#10898
Amaury Levé (Evangelink) wants to merge 4 commits into
mainfrom
dev/amauryleve/mtp-in-process-client-launch

Conversation

@Evangelink

Copy link
Copy Markdown
Member

Fixes#10890

Why

MtpServerClient.LaunchAsync(path) owns the loopback listener and starts the test application with Process.Start. That is right for IDE and desktop tooling, but unusable for embedded hosts — MAUI apps, Android/iOS test apps — where the MTP application already runs in the caller's process.

Today those hosts have to reimplement the listener, the --server/host/port arguments, the race between connection and startup failure, the serializer-before-formatter registration order, TcpMessageHandler + MtpJsonRpcConnection construction, and the exit / transport-close / server-completion shutdown dance. That defeats the point of shipping a canonical client and makes it easy to reintroduce bugs this package already solved: partial frame writes, ignored JSON-RPC errors, missing $/cancelRequest, unbounded waits, exception masking.

A concrete consumer is the DeviceRunners MSTest visual-runner work (mattleibow/DeviceRunners#157, #9809).

What

usingIMtpServerClientclient=awaitMtpServerClient.LaunchInProcessAsync(async(serverArgs,token)=>{ITestApplicationBuilderbuilder=awaitTestApplication.CreateBuilderAsync(serverArgs);builder.AddMSTest(()=>testAssemblies);usingITestApplicationapp=awaitbuilder.BuildAsync();returnawaitapp.RunAsync();},options,cancellationToken);awaitclient.InitializeAsync();awaitclient.DiscoverTestsAsync();awaitclient.RunTestsAsync();awaitclient.ExitAsync();awaitclient.ShutdownAsync();

The caller supplies only "how to run the application". The client keeps everything else: it binds the listener, generates the complete argument array (--server jsonrpc --client-host 127.0.0.1 --client-port <port> --no-banner), races the connect, builds the transport, and owns a bounded shutdown.

New API on the injected (still internal) surface:

MemberPurpose
MtpServerClient.LaunchInProcessAsync(callback, options, ct)The embedded-host launch path. Async only — blocking the launching thread can deadlock the application being launched.
IMtpServerClient.ShutdownAsync()Non-blocking teardown.
IMtpServerClient.ServerExitCodeThe value the application returned.
MtpServerClientOptions.ServerShutdownTimeoutGraceful shutdown bound (default 30s).

Internally, the transport setup was extracted from MtpServerProcess into MtpServerConnector, and both launch paths now sit behind IMtpServerHost, so they cannot drift.

Behavior worth reviewing

Ownership and shutdown. Both Dispose() and ShutdownAsync() join one lazily created shared teardown task, on both hosts. A Dispose that follows or races ShutdownAsync therefore returns only once the server has actually stopped, rather than reporting success while teardown is still running. Teardown runs on the thread pool, so ShutdownAsync never blocks and the synchronous Dispose cannot deadlock against a UI-thread continuation. Task.Run captures the execution context, so the connection's read-loop AsyncLocal marker still flows and disposing from a notification handler does not self-wait (covered by a test asserting < 4s against the connection's 5s read-loop timeout).

Bounded, and actually bounded. Teardown waits ServerShutdownTimeout, then cancels the callback's token, then a fixed 5s grace, then abandons and logs. CancellationTokenSource.Cancel() runs registrations synchronously, so the cancellation is started separately — otherwise a blocking caller registration would prevent the grace from ever starting and make the "bounded" wait unbounded. A failed launch skips the graceful wait entirely: nothing is connected, so there is no transport closure for the callback to observe.

Exception preservation. A callback that throws, is canceled, or returns before dialing back surfaces as MtpServerConnectionClosedException with the original as InnerException, instead of a misleading connection timeout. Every teardown helper is non-throwing, and the shared teardown task is wrapped so it can never fault — a faulted shared task would throw from every later disposal.

LaunchAsync(path) is unchanged. Verified against main: identical argument string, failure messages, stderr capture, exit fast-fail and teardown order. One earlier revision of this branch added a grace period to the accept race; review showed it only traded a precise failure for a vague one, so it was removed and the external path is now provably unchanged.

Browser/WASM. Both paths are loopback TCP. LaunchInProcessAsync fails fast with PlatformNotSupportedException there; this does not enable WASM hosting, and the docs say so.

Tests

  • 68 unit tests (net8.0) / 63 (net462, Jsonite path). New coverage: argument array shape; initialize → discover → run → exit; callback faulting synchronously and asynchronously; callback exiting with a code; null task; pre-canceled launch not invoking the callback; cancellation during connect canceling the callback token; connection timeout bounded by the grace rather than ServerShutdownTimeout; ShutdownAsync; ServerExitCode; disposal awaiting the callback; Dispose racing an in-flight ShutdownAsync; Dispose from a notification handler; idempotent disposal; callback faulting during shutdown; unresponsive callback abandoned within the bound; $/cancelRequest; stateful on/off; multi-request single connection; EnvironmentVariables ignored and warned. Stressed 6×/5× consecutively for flakiness.
  • New acceptance test where a single generated process is simultaneously the embedded host (compiling the packed source-only package) and a real MTP TestApplication with real MSTest over a real [TestClass] — discovering and running its own test over JSON-RPC with no Process.Start anywhere. It also asserts the server's reported process id equals its own.
  • Existing external-process acceptance test extended to cover ShutdownAsync + ServerExitCode; hostile-consumer compile oracle extended to bind the new API on net462/netstandard2.0/net5.0–net8.0.
  • Full regression: 13 platform source-package/consumer acceptance, 4 MSTest acceptance, 13 platform ServerTests. build.cmd -pack clean, 0 warnings.

Review history

Four independent review rounds (MTP/MSTest expert reviewer, a design reviewer, and two correctness reviewers) produced 3 major, 10 moderate and 10 minor findings, all addressed. Several were real bugs the tests then locked in — notably Process.ExitCode being unreadable after Process.Dispose(), a TcpListener socket leak when Start() failed, and a teardown path that could throw from a contract documented never to throw.

Open design questions

  1. No IAsyncDisposable. netstandard2.0/net462 would need Microsoft.Bcl.AsyncInterfaces, breaking the package's dependency-free promise. ShutdownAsync() is the substitute. Happy to revisit if the dependency is acceptable.
  2. MtpServerClientOptions is mode-mixed.EnvironmentVariables is external-process only; ServerShutdownTimeout is in-process only. Nesting per-transport options would age better, but EnvironmentVariables already shipped at the top level, so it cannot be done non-breakingly now.
  3. The 5s cancellation grace is a fixed constant, not an option.
  4. The two paths still build different argument shapes — deliberate, to keep the shipped external command line byte-identical. Commented at the call site.
  5. No ConnectAsync(TcpClient/Stream, options) factory — the existing MtpServerClient(MtpJsonRpcConnection, options) constructor already covers "wrap an existing transport", and the guidance is to minimize injected surface. Easy to add if wanted.

Embedded hosts such as MAUI or Android/iOS test apps cannot spawn a child
process, so `MtpServerClient.LaunchAsync(path)` was unusable for them and they
had to reimplement the listener, the server-mode arguments, the connect race,
the serializer/formatter ordering, the transport and the shutdown coordination
by hand -- reintroducing the bugs the canonical client already solves.
Add `MtpServerClient.LaunchInProcessAsync(callback, options, cancellationToken)`.
The client keeps ownership of everything except "how to run the application":
it binds the loopback listener, generates the complete server-mode argument
array, races the connect against callback failure/completion, caller
cancellation and the connection timeout, and owns a bounded shutdown that
closes the transport, then cancels the callback token, then abandons it rather
than hanging the caller. A callback failure before connection is surfaced as
`MtpServerConnectionClosedException` with the caller's exception preserved as
the inner exception, and teardown failures are only logged so they can never
replace the primary failure.
The shared transport setup is extracted from `MtpServerProcess` into
`MtpServerConnector` and both launch paths now flow through `IMtpServerHost`,
so the external-process behavior is unchanged while the two paths cannot drift.
The one behavior change is an improvement shared by both: when the server is
seen to have stopped, a still-pending accept gets a bounded final grace so a
connection established just before the stop is not discarded in favor of a
misleading "stopped before connecting back" failure.
The path is loopback TCP, so it fails fast with `PlatformNotSupportedException`
on browser/WASM; it does not enable WASM hosting.
Fixes#10890
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two independent reviews of the in-process launch path raised the same top
finding: `Dispose()` waits for the hosted application synchronously on the
calling thread, and on the very platforms this feature targets (MAUI, Android,
iOS) a multi-second block on the UI thread trips the ANR / watchdog. Add
`IMtpServerClient.ShutdownAsync()` (and `IMtpServerHost.ShutdownAsync`) so the
same teardown can be awaited instead. `IAsyncDisposable` stays rejected --
netstandard2.0 and net462 would need `Microsoft.Bcl.AsyncInterfaces`, which
breaks the package's dependency-free promise -- but rejecting the interface is
not a reason to have no asynchronous shutdown at all. `Dispose()` remains, is
still idempotent, and returns immediately after `ShutdownAsync`.
Remove the accept grace period. The previous commit let a still-pending accept
win for ~100ms after the server was seen to have stopped, on the theory that a
connection established just before the stop should not be discarded. That
theory does not hold: if the server has stopped, the socket belongs to a dead
peer, so the grace only traded a precise failure (exit code plus captured
stderr) for a generic connection-closed error on the first request, and its
uncancellable delay could let the server failure beat a concurrent caller
cancellation. Dropping it also makes the external-process path provably
unchanged, so the changelog no longer needs a `Changed` entry.
Other review fixes:
* Expose `IMtpServerClient.ServerExitCode`. The callback signature already
demanded a `Task<int>`, but after a successful session the value was
unreachable, so an embedded host whose `Main` must return it had to capture
it in a closure.
* Do not dispose the server's `CancellationTokenSource` when the callback was
abandoned while still running: it holds the token, and `token.WaitHandle` or
`CreateLinkedTokenSource` would then throw `ObjectDisposedException` inside
the caller's own code.
* Skip the graceful wait entirely on a failed launch. Nothing is connected, so
there is no transport closure for the callback to observe; only the fixed
cancellation grace applies and an unwinding caller no longer pays
`ServerShutdownTimeout`.
* Clamp bounded waits instead of trusting the caller's `TimeSpan`. A negative
value (or `Timeout.InfiniteTimeSpan`) made `Task.Delay` throw from a path
documented never to throw; an oversized one is capped to the largest delay
.NET Framework accepts.
* Move `TcpListener.Start()` inside the cleanup `try`: it creates the socket
before binding, so a bind failure leaked it because the caller never received
a listener it could stop.
* Log a late failure from an abandoned callback rather than only observing it.
* Cache the in-process host's `ProcessId` instead of allocating a `Process` per
property read.
* Document that `Dispose()` blocks, that cancellation is bounded rather than
immediate, and why the two launch paths still build different argument
shapes; fix the shadowed `cancellationToken` in the PACKAGE.md sample.
Tests: cover `ShutdownAsync`, `ServerExitCode`, and fail-fast on a server that
stops without connecting; make `Dispose_IsIdempotent` able to fail by asserting
on observed transport closes rather than callback invocations; replace the
hand-rolled throws helper with `Assert.ThrowsExactlyAsync`; tighten the
shutdown-bound assertion; and stop leaking `Process` handles.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A third review round found three ways the teardown contract did not hold up
under concurrency.
`Dispose()` and `ShutdownAsync()` each had their own `Interlocked.Exchange`
guard, so whichever call lost the race returned while teardown was still
running -- reporting to its caller that the application had stopped when it had
not. Replace both guards with one lazily started, shared teardown task: every
entry point now joins the same task, so `Dispose` after (or racing)
`ShutdownAsync` returns only once the application has actually stopped, and the
whole thing stays idempotent. `MtpServerClient` no longer keeps its own guard
either; it delegates to the host, whose teardown is the single source of truth.
`ShutdownAsync()` could still block the very thread it exists to protect: it
closed the transport before its first await, and closing the connection waits
up to five seconds for the read loop. The shared task is started with
`Task.Run`, so the whole teardown -- transport close included -- runs on the
thread pool and `ShutdownAsync` returns immediately. `MtpServerProcess`
likewise now runs its (bounded but synchronous) kill off the calling thread
rather than pretending to be async while blocking.
`CancellationTokenSource.Cancel()` executes registrations synchronously on the
calling thread, so a caller registration that blocked would prevent the
five-second cancellation grace from ever starting and make the "bounded"
shutdown unbounded. Start the cancellation separately and begin the grace
regardless. When a registration is still executing once the callback has
finished, the source is reported as unsafe to dispose for the same reason an
abandoned callback is: leaking one `CancellationTokenSource` beats a
use-after-dispose inside the caller's code.
Adds a test that a `Dispose` racing an in-flight `ShutdownAsync` blocks until
the shared teardown completes, and that both share one teardown.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… fix ExitCode
A fourth review round found that only the in-process host had been converted to
the shared-teardown design. `MtpServerProcess` kept its old early-return guard,
so on the external-process path a `Dispose()` that followed or raced
`ShutdownAsync()` returned while `SafeKill`'s bounded `WaitForExit` was still
running on another thread -- losing exactly the guarantee that wait exists for
(a caller may delete the application directory immediately after disposal), and
making `await ShutdownAsync()` report that the server had stopped when it had
not. That contradicted the comment the previous commit added to
`MtpServerClient.Dispose`, which claimed both paths joined an in-flight
teardown. Convert `MtpServerProcess` to the same lazily created shared task, so
the contract is now uniform across both implementations of `IMtpServerHost`,
and reconcile the interface docs, which previously described the opposite rule.
Adding real coverage for `ShutdownAsync` on the external-process path then
surfaced a genuine bug: `MtpServerProcess.ExitCode` was always `null` after
teardown, because a `Process` cannot be queried once disposed. Capture the exit
code during teardown instead -- before the kill, so an application that already
exited on its own reports its real code rather than the kill's, and before
`Process.Dispose()`, after which nothing is readable.
Also fixes a race in the in-process test fixture that made the suite flaky on
net462: the callback published its `FakeMtpServer` only after its own connect
call returned, but the client's accept can complete first, so a test could
reach `Value` before the callback had set it. The fixture now exposes a
`Connected` signal the launch helper awaits. 6/6 clean net462 runs and 5/5
clean net8.0 runs afterwards.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI balanced review requested due to automatic review settings August 31, 2026 19:22
Comment on lines +182 to +193
if (serverTask is not null)
{
// 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.
if (!await ShutdownServerAsync(serverTask, serverCancellation!, TimeSpan.Zero, logger).ConfigureAwait(false))
{
// 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;
}
}
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Expert test review — PR #10898

GradeTestMutationNotesHow to improve
B (80–89)new MtpServerClientInProcessTests.
Dispose_CallbackFaultsDuringShutdown_
DoesNotThrow
2/3 killedReads server.Completion.Exception but never asserts on it, so a mutation changing the faulted exception's identity/message survives.Assert the captured exception's message/type instead of discarding it after touching .Exception.
B (80–89)new MtpServerClientInProcessTests.
LaunchInProcessAsync_HonorsTheStatefulOption
3/4 killedOnly asserts the sent initialize args; a mutation dropping the negotiated round-trip value on client.Capabilities is not caught.Also assert client.Capabilities.IsStateful reflects the negotiated round-trip value, not only the sent request.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_PassesCompleteServerModeArguments
5/5 killedVerifies exact ordered arguments, dynamic port and fixed count; strong regression guard for the generated argument array.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_DrivesInitializeDiscoverRunAndExit
6/6 killedEnd-to-end drive through initialize/discover/run/exit with distinct assertions per state transition.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackFaultsBeforeConnecting_
PreservesCallbackException
2/2 killedAsserts the exact exception instance is preserved as inner exception, not just its type.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackFaultsAsynchronouslyBeforeConnecting_
PreservesCallbackException
2/2 killedCovers the async-throw variant with the same identity assertion as the sync case.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackExitsWithoutConnecting_
FailsFastInsteadOfWaitingOutTheTimeout
3/3 killedChecks both the reported exit code in the message and a timing bound guarding against a slow-timeout regression.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackExitsBeforeConnecting_
ReportsExitCode
2/2 killedAsserts the specific exit code surfaces in the exception message.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackReturnsNullTask_Fails
2/2 killedAsserts the specific inner exception type for a misbehaving callback returning a null task.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_NullCallback_Throws
1/1 killedFocused single-assertion guard-clause test using the exact exception type.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_AlreadyCanceled_
DoesNotInvokeCallback
2/2 killedVerifies both the cancellation exception and, via an interlocked counter, that the callback never ran.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CanceledWhileConnecting_
CancelsTheCallbackToken
2/2 killedConfirms both the caller-side cancellation and that the callback's own token observed cancellation.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_ConnectionTimeoutElapses_
FailsWithTimeoutMessage
3/3 killedDistinguishes the connection timeout from the deliberately huge shutdown timeout via message and elapsed-time bound.
A (90–100)new MtpServerClientInProcessTests.
Dispose_ClosesTransportAndAwaitsTheCallback
4/4 killedChecks pre/post completion state, the callback's returned exit code, and the client's reported exit code.
A (90–100)new MtpServerClientInProcessTests.
ShutdownAsync_ClosesTransportAndAwaitsTheCallback_
WithoutBlocking
4/4 killedVerifies await-completion, exit code, a fast follow-up Dispose, and single-teardown count together.
A (90–100)new MtpServerClientInProcessTests.
Dispose_FromANotificationHandler_
DoesNotSelfWaitOnTheReadLoop
1/1 killedTargeted timing assertion guards against a specific re-entrancy deadlock regression.
A (90–100)new MtpServerClientInProcessTests.
Dispose_WhileShutdownAsyncIsInFlight_
WaitsForTheSameTeardown
3/3 killedUses a controlled release gate to prove the racing Dispose genuinely blocks, then confirms a single shared teardown.
A (90–100)new MtpServerClientInProcessTests.
Dispose_IsIdempotent
3/3 killedConfirms exactly one transport close and one completion despite three Dispose calls, plus a fast-return bound.
A (90–100)new MtpServerClientInProcessTests.
Dispose_CallbackIgnoresShutdown_
ReturnsWithinTheDocumentedBound
2/2 killedVerifies both the documented abandonment time bound and the logged "abandoning" diagnostic.
A (90–100)new MtpServerClientInProcessTests.
RunTestsAsync_Canceled_
SendsCancelRequestToTheHostedApplication
2/2 killedConfirms both the client-side cancellation exception and the wire-level cancel notification reaching the fake server.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_MultipleRequestsOnOneSession_
ReuseTheSameConnection
3/3 killedChecks keep-alive negotiation, single-connection reuse count, and total request count together.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_IgnoresEnvironmentVariablesAndWarns
1/1 killedAsserts the specific warning content naming the ignored option.
A (90–100)new MtpServerClientInProcessAcceptanceTests.
InProcessHost_
DiscoversAndRunsItsOwnMSTestNodes_
WithoutStartingAProcess
5/5 killedReal end-to-end embedded-host run asserting build success, exit code, and each marker line the generated app emits.
A (90–100)mod MtpServerClientAcceptanceTests.
DiscoverAndRun_ViaSourcePackageClient_
ReportsExpectedTestNode
1/1 killedNew lines assert the external-process ShutdownAsync/Dispose share a teardown and report an exit code.
A (90–100)mod MtpServerClientSourcePackageConsumerTests.
HostileConsumer_
CompilesAgainstPackedSource
N/ACompile-only guard; added lines correctly extend surface coverage to ShutdownAsync and the in-process launch path.

This advisory comment was generated automatically. Grades are heuristic
and informational — they do not block merging. Suggestions on the Files
changed tab can be applied with one click. Re-run with
/review-tests.

🤖 Automated content by GitHub Copilot. Generated by the Test Reviewer on PR (on open / sync) workflow. · auto · 159.6 AIC · ⌖ 2.87 AIC · ⊞ 16.9K · [◷]( · )

@github-actions

Copy link
Copy Markdown
Contributor

🧵 Parallel-safety audit — PR #10898

Parallelization — audited assemblies:

Test assemblyScopeWorkersAnalyzer coverage
MSTest.Acceptance.IntegrationTestsMethodLevel (default [assembly: Parallelize(Scope = ExecutionScope.MethodLevel, Workers = 0)] in Program.cs)CPU countcoverable once the parallel-safety analyzers ship (attribute-based opt-in)
Microsoft.Testing.Platform.Acceptance.IntegrationTestsMethodLevel (Program.cs)CPU countcoverable once shipped
Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTestsMethodLevel (Program.cs)CPU countcoverable once shipped

This PR did not touch any .runsettings/testconfig.json/Directory.Build.* parallelization config — no assembly's scope changed.

Findings: A (global-state) 0 · B (paths) 0 · C (declaration) 0 · D (over-serialization) 0 — by severity: Critical 0 · High 0 · Warning 0 · Info 0.

No unsafe call sites found in the lines this PR added or modified.

  • MtpServerClientAcceptanceTests.cs — added lines only call client.ShutdownAsync() / assert ServerExitCode; no process-global state touched.
  • MtpServerClientSourcePackageConsumerTests.cs — added lines call into an isolated generated test asset (DriveAsync/DriveInProcessAsync); no shared/relative paths, env vars, culture, or console state mutated at the assembly level (Console.WriteLine here writes to the generated app's own redirected output, not the auditing process).
  • MtpServerClientInProcessAcceptanceTests.cs (new) — uses TestAsset.GenerateAssetAsync, which allocates a unique GUID-suffixed TempDirectory per instance (TestAsset.cs:19-26, throws if a path already exists) — no shared-path collision. CreateChildEnvironment() builds a fresh Dictionary passed only to the spawned child process's environment, not Environment.SetEnvironmentVariable, so it does not mutate this process's state.
  • FakeMtpServer.cs / MtpServerClientInProcessTests.cs (new) — each test constructs its own TcpListener/FakeMtpServer bound to an OS-assigned loopback port (new TcpListener(IPAddress.Loopback, 0)), so there is no fixed-port collision between concurrently-running tests; no static/process-global state is written.

Nothing here implies a testability or smell concern beyond this audit's scope.

Advisory only — heuristic, non-blocking. Re-run with /parallel-audit. This audit answers "is it parallel-safe?"; for testability, smells, or flakiness see the detect-static-dependencies / test-smell-detection / test-anti-patterns analyses.

🤖 Automated content by GitHub Copilot. Generated by the Parallel-safety audit on PR (on open / sync) workflow. · auto · 117.1 AIC · ⌖ 3.53 AIC · ⊞ 24.8K · [◷]( · )

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated content by GitHub Copilot. Generated by the Test Reviewer on PR (on open / sync) workflow. · auto · 159.6 AIC · ⌖ 2.87 AIC · ⊞ 16.9K ·

client.Dispose();

Assert.IsTrue(server.Completion.IsFaulted);
_ = server.Completion.Exception;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test review · Grade B (80–89) — Reads server.Completion.Exception but never asserts on it, so a mutation changing the faulted exception's identity/message would survive.

Assert the captured exception's message/type instead of discarding it after touching .Exception.

Suggested change
_=server.Completion.Exception;
Exception?faultException=server.Completion.Exception?.GetBaseException();
Assert.IsInstanceOfType<InvalidOperationException>(faultException);
Assert.AreEqual("The application failed while shutting down.",faultException!.Message);

? typed
: SerializerUtilities.Deserialize<InitializeRequestArgs>((IDictionary<string, object?>)initialize.Params!);

Assert.AreEqual(isStateful, args.Capabilities.IsStateful, "The in-process path must forward the client's stateful capability unchanged.");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test review · Grade B (80–89) — Only asserts the sent initialize args; a mutation dropping the negotiated round-trip value on client.Capabilities is not caught.

Also assert client.Capabilities.IsStateful reflects the negotiated round-trip value, not only the sent request.

Suggested change
Assert.AreEqual(isStateful,args.Capabilities.IsStateful,"The in-process path must forward the client's stateful capability unchanged.");
Assert.AreEqual(isStateful,args.Capabilities.IsStateful,"The in-process path must forward the client's stateful capability unchanged.");
Assert.AreEqual(isStateful,capabilities.IsStateful,"The negotiated capability the client stores must reflect the server's round trip, not just the sent request.");

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

🤖 Automated review by GitHub Copilot. Generated by the Expert Code Review workflow. To request a follow-up action, reply by tagging @copilot directly.

✅ 22/22 dimensions clean — no findings.

Summary: This PR adds MtpServerClient.LaunchInProcessAsync for embedded hosts (MAUI, Android/iOS) that cannot Process.Start. The implementation is thorough and well-structured:

  • Threading & Concurrency: Shutdown is idempotent via lock + lazy Task, Volatile.Read/Write for cross-thread exit code, Task.Run avoids sync-context deadlocks. No shared mutable state without synchronization.
  • Security & IPC: Loopback TCP only, no command-line injection surface (arguments are array-based), WASM fails fast with PlatformNotSupportedException.
  • Public API: All new API is internal (source-only package). No init accessors. No PublicAPI.Unshipped.txt changes needed.
  • Performance: GetCurrentProcessId() snapshotted once in constructor. No hot-path allocations. WaitBoundedAsync clamps timeouts correctly.
  • Cross-TFM: #if NET8_0_OR_GREATER guard on AcceptTcpClientAsync(CancellationToken). RuntimeInformation used for OS detection on net462.
  • Resource Management: Every disposable (TcpListener, TcpClient, CancellationTokenSource, Process) has cleanup in both success and error paths. Pending accepts are neutralized. ServerCancellation is only disposed when safe.
  • Defensive Coding: Callback exceptions are wrapped as InnerException in MtpServerConnectionClosedException. Null task from callback is caught. Cancel() runs on thread pool to avoid blocking registration.
  • Error Handling: Shared teardown task wrapped in catch-all so it never faults. ObserveFailure prevents UnobservedTaskException. Every teardown helper is non-throwing.
  • Tests: 68 unit tests + acceptance tests covering argument shape, lifecycle, faults, cancellation, timeouts, idempotent disposal, notification-handler disposal, and the real end-to-end in-process path.
  • Documentation: Changelog, protocol intro, PACKAGE.md all updated. XML doc comments are thorough.

The refactoring of shared transport logic into MtpServerConnector and the IMtpServerHost abstraction is clean and prevents the two launch paths from drifting. The existing LaunchAsync(path) path is provably unchanged (same argument string, same failure messages, same teardown order).

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

Review tier: Balanced
Findings: 3 Medium severity

New issues introduced by this change (3)
SeverityFinding
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.csMtpJsonRpcConnection.Dispose() closes the socket and then waits up to its 5-second read-loop…
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.csShutdownAsync writes this nullable property from the teardown worker while callers can read…
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.cs — A canceled callback takes this branch without an inner exception, although the package…
What changed in this PR

Adds in-process launch support to the source-only MTP server-mode client for embedded/mobile hosts, addressing #10890 and enabling scenarios related to #9809.

Changes:

  • Adds callback-based in-process hosting with shared transport setup.
  • Adds bounded asynchronous shutdown and server exit-code reporting.
  • Adds documentation, unit coverage, and real-MSTest acceptance coverage.
FileDescription
MtpServerClientInProcessTests.csTests in-process lifecycle and protocol behavior.
FakeMtpServer.csSupports server-to-client dial-back mode.
MtpServerClientInProcessAcceptanceTests.csExercises a real in-process MSTest application.
MtpServerClientAcceptanceTests.csCovers external-process shutdown and exit code.
MtpServerClientSourcePackageConsumerTests.csExtends source-package compile coverage.
PACKAGE.mdDocuments embedded-host usage and lifecycle.
MtpServerProcess.csAdopts shared host and shutdown abstractions.
MtpServerInProcessHost.csImplements callback hosting and teardown.
MtpServerConnector.csCentralizes listener and transport setup.
MtpServerClientOptions.csAdds the shutdown timeout option.
MtpServerClientExceptions.csSupports preserved inner exceptions.
MtpServerClient.csExposes in-process launch and shutdown.
IMtpServerHost.csDefines common host ownership behavior.
IMtpServerClient.csAdds shutdown and exit-code members.
001-protocol-intro.mdDocuments reference-client launch modes.
Changelog-Platform.mdRecords the new embedded-host capability.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +254 to +258
Connection.Dispose();
SafeDispose(_client, _logger, "Disposing the accepted client socket");
MtpServerConnector.SafeStop(_listener, _logger);

bool stopped = await ShutdownServerAsync(_serverTask, _serverCancellation, _shutdownTimeout, _logger).ConfigureAwait(false);
/// Gets the exit code the hosted application returned, or <see langword="null"/> while it is still
/// running (or when it failed or was abandoned rather than returning one).
/// </summary>
public int? ExitCode { get; private set; }
Comment on lines +311 to +315
if (serverTask.IsCanceled)
{
return new MtpServerConnectionClosedException(
"The in-process Microsoft.Testing.Platform application was canceled before connecting back.");
}
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add in-process launch support to the MTP server-mode client

2 participants

@Evangelink
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add in-process launch support to the MTP server-mode client by Evangelink · Pull Request #10898 · microsoft/testfx · GitHub
Skip to content

Add in-process launch support to the MTP server-mode client - #10898

Open
Amaury Levé (Evangelink) wants to merge 4 commits into
mainfrom
dev/amauryleve/mtp-in-process-client-launch
Open

Add in-process launch support to the MTP server-mode client#10898
Amaury Levé (Evangelink) wants to merge 4 commits into
mainfrom
dev/amauryleve/mtp-in-process-client-launch

Conversation

@Evangelink

Copy link
Copy Markdown
Member

Fixes#10890

Why

MtpServerClient.LaunchAsync(path) owns the loopback listener and starts the test application with Process.Start. That is right for IDE and desktop tooling, but unusable for embedded hosts — MAUI apps, Android/iOS test apps — where the MTP application already runs in the caller's process.

Today those hosts have to reimplement the listener, the --server/host/port arguments, the race between connection and startup failure, the serializer-before-formatter registration order, TcpMessageHandler + MtpJsonRpcConnection construction, and the exit / transport-close / server-completion shutdown dance. That defeats the point of shipping a canonical client and makes it easy to reintroduce bugs this package already solved: partial frame writes, ignored JSON-RPC errors, missing $/cancelRequest, unbounded waits, exception masking.

A concrete consumer is the DeviceRunners MSTest visual-runner work (mattleibow/DeviceRunners#157, #9809).

What

usingIMtpServerClientclient=awaitMtpServerClient.LaunchInProcessAsync(async(serverArgs,token)=>{ITestApplicationBuilderbuilder=awaitTestApplication.CreateBuilderAsync(serverArgs);builder.AddMSTest(()=>testAssemblies);usingITestApplicationapp=awaitbuilder.BuildAsync();returnawaitapp.RunAsync();},options,cancellationToken);awaitclient.InitializeAsync();awaitclient.DiscoverTestsAsync();awaitclient.RunTestsAsync();awaitclient.ExitAsync();awaitclient.ShutdownAsync();

The caller supplies only "how to run the application". The client keeps everything else: it binds the listener, generates the complete argument array (--server jsonrpc --client-host 127.0.0.1 --client-port <port> --no-banner), races the connect, builds the transport, and owns a bounded shutdown.

New API on the injected (still internal) surface:

MemberPurpose
MtpServerClient.LaunchInProcessAsync(callback, options, ct)The embedded-host launch path. Async only — blocking the launching thread can deadlock the application being launched.
IMtpServerClient.ShutdownAsync()Non-blocking teardown.
IMtpServerClient.ServerExitCodeThe value the application returned.
MtpServerClientOptions.ServerShutdownTimeoutGraceful shutdown bound (default 30s).

Internally, the transport setup was extracted from MtpServerProcess into MtpServerConnector, and both launch paths now sit behind IMtpServerHost, so they cannot drift.

Behavior worth reviewing

Ownership and shutdown. Both Dispose() and ShutdownAsync() join one lazily created shared teardown task, on both hosts. A Dispose that follows or races ShutdownAsync therefore returns only once the server has actually stopped, rather than reporting success while teardown is still running. Teardown runs on the thread pool, so ShutdownAsync never blocks and the synchronous Dispose cannot deadlock against a UI-thread continuation. Task.Run captures the execution context, so the connection's read-loop AsyncLocal marker still flows and disposing from a notification handler does not self-wait (covered by a test asserting < 4s against the connection's 5s read-loop timeout).

Bounded, and actually bounded. Teardown waits ServerShutdownTimeout, then cancels the callback's token, then a fixed 5s grace, then abandons and logs. CancellationTokenSource.Cancel() runs registrations synchronously, so the cancellation is started separately — otherwise a blocking caller registration would prevent the grace from ever starting and make the "bounded" wait unbounded. A failed launch skips the graceful wait entirely: nothing is connected, so there is no transport closure for the callback to observe.

Exception preservation. A callback that throws, is canceled, or returns before dialing back surfaces as MtpServerConnectionClosedException with the original as InnerException, instead of a misleading connection timeout. Every teardown helper is non-throwing, and the shared teardown task is wrapped so it can never fault — a faulted shared task would throw from every later disposal.

LaunchAsync(path) is unchanged. Verified against main: identical argument string, failure messages, stderr capture, exit fast-fail and teardown order. One earlier revision of this branch added a grace period to the accept race; review showed it only traded a precise failure for a vague one, so it was removed and the external path is now provably unchanged.

Browser/WASM. Both paths are loopback TCP. LaunchInProcessAsync fails fast with PlatformNotSupportedException there; this does not enable WASM hosting, and the docs say so.

Tests

  • 68 unit tests (net8.0) / 63 (net462, Jsonite path). New coverage: argument array shape; initialize → discover → run → exit; callback faulting synchronously and asynchronously; callback exiting with a code; null task; pre-canceled launch not invoking the callback; cancellation during connect canceling the callback token; connection timeout bounded by the grace rather than ServerShutdownTimeout; ShutdownAsync; ServerExitCode; disposal awaiting the callback; Dispose racing an in-flight ShutdownAsync; Dispose from a notification handler; idempotent disposal; callback faulting during shutdown; unresponsive callback abandoned within the bound; $/cancelRequest; stateful on/off; multi-request single connection; EnvironmentVariables ignored and warned. Stressed 6×/5× consecutively for flakiness.
  • New acceptance test where a single generated process is simultaneously the embedded host (compiling the packed source-only package) and a real MTP TestApplication with real MSTest over a real [TestClass] — discovering and running its own test over JSON-RPC with no Process.Start anywhere. It also asserts the server's reported process id equals its own.
  • Existing external-process acceptance test extended to cover ShutdownAsync + ServerExitCode; hostile-consumer compile oracle extended to bind the new API on net462/netstandard2.0/net5.0–net8.0.
  • Full regression: 13 platform source-package/consumer acceptance, 4 MSTest acceptance, 13 platform ServerTests. build.cmd -pack clean, 0 warnings.

Review history

Four independent review rounds (MTP/MSTest expert reviewer, a design reviewer, and two correctness reviewers) produced 3 major, 10 moderate and 10 minor findings, all addressed. Several were real bugs the tests then locked in — notably Process.ExitCode being unreadable after Process.Dispose(), a TcpListener socket leak when Start() failed, and a teardown path that could throw from a contract documented never to throw.

Open design questions

  1. No IAsyncDisposable. netstandard2.0/net462 would need Microsoft.Bcl.AsyncInterfaces, breaking the package's dependency-free promise. ShutdownAsync() is the substitute. Happy to revisit if the dependency is acceptable.
  2. MtpServerClientOptions is mode-mixed.EnvironmentVariables is external-process only; ServerShutdownTimeout is in-process only. Nesting per-transport options would age better, but EnvironmentVariables already shipped at the top level, so it cannot be done non-breakingly now.
  3. The 5s cancellation grace is a fixed constant, not an option.
  4. The two paths still build different argument shapes — deliberate, to keep the shipped external command line byte-identical. Commented at the call site.
  5. No ConnectAsync(TcpClient/Stream, options) factory — the existing MtpServerClient(MtpJsonRpcConnection, options) constructor already covers "wrap an existing transport", and the guidance is to minimize injected surface. Easy to add if wanted.

Embedded hosts such as MAUI or Android/iOS test apps cannot spawn a child
process, so `MtpServerClient.LaunchAsync(path)` was unusable for them and they
had to reimplement the listener, the server-mode arguments, the connect race,
the serializer/formatter ordering, the transport and the shutdown coordination
by hand -- reintroducing the bugs the canonical client already solves.
Add `MtpServerClient.LaunchInProcessAsync(callback, options, cancellationToken)`.
The client keeps ownership of everything except "how to run the application":
it binds the loopback listener, generates the complete server-mode argument
array, races the connect against callback failure/completion, caller
cancellation and the connection timeout, and owns a bounded shutdown that
closes the transport, then cancels the callback token, then abandons it rather
than hanging the caller. A callback failure before connection is surfaced as
`MtpServerConnectionClosedException` with the caller's exception preserved as
the inner exception, and teardown failures are only logged so they can never
replace the primary failure.
The shared transport setup is extracted from `MtpServerProcess` into
`MtpServerConnector` and both launch paths now flow through `IMtpServerHost`,
so the external-process behavior is unchanged while the two paths cannot drift.
The one behavior change is an improvement shared by both: when the server is
seen to have stopped, a still-pending accept gets a bounded final grace so a
connection established just before the stop is not discarded in favor of a
misleading "stopped before connecting back" failure.
The path is loopback TCP, so it fails fast with `PlatformNotSupportedException`
on browser/WASM; it does not enable WASM hosting.
Fixes#10890
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two independent reviews of the in-process launch path raised the same top
finding: `Dispose()` waits for the hosted application synchronously on the
calling thread, and on the very platforms this feature targets (MAUI, Android,
iOS) a multi-second block on the UI thread trips the ANR / watchdog. Add
`IMtpServerClient.ShutdownAsync()` (and `IMtpServerHost.ShutdownAsync`) so the
same teardown can be awaited instead. `IAsyncDisposable` stays rejected --
netstandard2.0 and net462 would need `Microsoft.Bcl.AsyncInterfaces`, which
breaks the package's dependency-free promise -- but rejecting the interface is
not a reason to have no asynchronous shutdown at all. `Dispose()` remains, is
still idempotent, and returns immediately after `ShutdownAsync`.
Remove the accept grace period. The previous commit let a still-pending accept
win for ~100ms after the server was seen to have stopped, on the theory that a
connection established just before the stop should not be discarded. That
theory does not hold: if the server has stopped, the socket belongs to a dead
peer, so the grace only traded a precise failure (exit code plus captured
stderr) for a generic connection-closed error on the first request, and its
uncancellable delay could let the server failure beat a concurrent caller
cancellation. Dropping it also makes the external-process path provably
unchanged, so the changelog no longer needs a `Changed` entry.
Other review fixes:
* Expose `IMtpServerClient.ServerExitCode`. The callback signature already
demanded a `Task<int>`, but after a successful session the value was
unreachable, so an embedded host whose `Main` must return it had to capture
it in a closure.
* Do not dispose the server's `CancellationTokenSource` when the callback was
abandoned while still running: it holds the token, and `token.WaitHandle` or
`CreateLinkedTokenSource` would then throw `ObjectDisposedException` inside
the caller's own code.
* Skip the graceful wait entirely on a failed launch. Nothing is connected, so
there is no transport closure for the callback to observe; only the fixed
cancellation grace applies and an unwinding caller no longer pays
`ServerShutdownTimeout`.
* Clamp bounded waits instead of trusting the caller's `TimeSpan`. A negative
value (or `Timeout.InfiniteTimeSpan`) made `Task.Delay` throw from a path
documented never to throw; an oversized one is capped to the largest delay
.NET Framework accepts.
* Move `TcpListener.Start()` inside the cleanup `try`: it creates the socket
before binding, so a bind failure leaked it because the caller never received
a listener it could stop.
* Log a late failure from an abandoned callback rather than only observing it.
* Cache the in-process host's `ProcessId` instead of allocating a `Process` per
property read.
* Document that `Dispose()` blocks, that cancellation is bounded rather than
immediate, and why the two launch paths still build different argument
shapes; fix the shadowed `cancellationToken` in the PACKAGE.md sample.
Tests: cover `ShutdownAsync`, `ServerExitCode`, and fail-fast on a server that
stops without connecting; make `Dispose_IsIdempotent` able to fail by asserting
on observed transport closes rather than callback invocations; replace the
hand-rolled throws helper with `Assert.ThrowsExactlyAsync`; tighten the
shutdown-bound assertion; and stop leaking `Process` handles.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A third review round found three ways the teardown contract did not hold up
under concurrency.
`Dispose()` and `ShutdownAsync()` each had their own `Interlocked.Exchange`
guard, so whichever call lost the race returned while teardown was still
running -- reporting to its caller that the application had stopped when it had
not. Replace both guards with one lazily started, shared teardown task: every
entry point now joins the same task, so `Dispose` after (or racing)
`ShutdownAsync` returns only once the application has actually stopped, and the
whole thing stays idempotent. `MtpServerClient` no longer keeps its own guard
either; it delegates to the host, whose teardown is the single source of truth.
`ShutdownAsync()` could still block the very thread it exists to protect: it
closed the transport before its first await, and closing the connection waits
up to five seconds for the read loop. The shared task is started with
`Task.Run`, so the whole teardown -- transport close included -- runs on the
thread pool and `ShutdownAsync` returns immediately. `MtpServerProcess`
likewise now runs its (bounded but synchronous) kill off the calling thread
rather than pretending to be async while blocking.
`CancellationTokenSource.Cancel()` executes registrations synchronously on the
calling thread, so a caller registration that blocked would prevent the
five-second cancellation grace from ever starting and make the "bounded"
shutdown unbounded. Start the cancellation separately and begin the grace
regardless. When a registration is still executing once the callback has
finished, the source is reported as unsafe to dispose for the same reason an
abandoned callback is: leaking one `CancellationTokenSource` beats a
use-after-dispose inside the caller's code.
Adds a test that a `Dispose` racing an in-flight `ShutdownAsync` blocks until
the shared teardown completes, and that both share one teardown.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… fix ExitCode
A fourth review round found that only the in-process host had been converted to
the shared-teardown design. `MtpServerProcess` kept its old early-return guard,
so on the external-process path a `Dispose()` that followed or raced
`ShutdownAsync()` returned while `SafeKill`'s bounded `WaitForExit` was still
running on another thread -- losing exactly the guarantee that wait exists for
(a caller may delete the application directory immediately after disposal), and
making `await ShutdownAsync()` report that the server had stopped when it had
not. That contradicted the comment the previous commit added to
`MtpServerClient.Dispose`, which claimed both paths joined an in-flight
teardown. Convert `MtpServerProcess` to the same lazily created shared task, so
the contract is now uniform across both implementations of `IMtpServerHost`,
and reconcile the interface docs, which previously described the opposite rule.
Adding real coverage for `ShutdownAsync` on the external-process path then
surfaced a genuine bug: `MtpServerProcess.ExitCode` was always `null` after
teardown, because a `Process` cannot be queried once disposed. Capture the exit
code during teardown instead -- before the kill, so an application that already
exited on its own reports its real code rather than the kill's, and before
`Process.Dispose()`, after which nothing is readable.
Also fixes a race in the in-process test fixture that made the suite flaky on
net462: the callback published its `FakeMtpServer` only after its own connect
call returned, but the client's accept can complete first, so a test could
reach `Value` before the callback had set it. The fixture now exposes a
`Connected` signal the launch helper awaits. 6/6 clean net462 runs and 5/5
clean net8.0 runs afterwards.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI balanced review requested due to automatic review settings August 31, 2026 19:22
Comment on lines +182 to +193
if (serverTask is not null)
{
// 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.
if (!await ShutdownServerAsync(serverTask, serverCancellation!, TimeSpan.Zero, logger).ConfigureAwait(false))
{
// 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;
}
}
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Expert test review — PR #10898

GradeTestMutationNotesHow to improve
B (80–89)new MtpServerClientInProcessTests.
Dispose_CallbackFaultsDuringShutdown_
DoesNotThrow
2/3 killedReads server.Completion.Exception but never asserts on it, so a mutation changing the faulted exception's identity/message survives.Assert the captured exception's message/type instead of discarding it after touching .Exception.
B (80–89)new MtpServerClientInProcessTests.
LaunchInProcessAsync_HonorsTheStatefulOption
3/4 killedOnly asserts the sent initialize args; a mutation dropping the negotiated round-trip value on client.Capabilities is not caught.Also assert client.Capabilities.IsStateful reflects the negotiated round-trip value, not only the sent request.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_PassesCompleteServerModeArguments
5/5 killedVerifies exact ordered arguments, dynamic port and fixed count; strong regression guard for the generated argument array.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_DrivesInitializeDiscoverRunAndExit
6/6 killedEnd-to-end drive through initialize/discover/run/exit with distinct assertions per state transition.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackFaultsBeforeConnecting_
PreservesCallbackException
2/2 killedAsserts the exact exception instance is preserved as inner exception, not just its type.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackFaultsAsynchronouslyBeforeConnecting_
PreservesCallbackException
2/2 killedCovers the async-throw variant with the same identity assertion as the sync case.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackExitsWithoutConnecting_
FailsFastInsteadOfWaitingOutTheTimeout
3/3 killedChecks both the reported exit code in the message and a timing bound guarding against a slow-timeout regression.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackExitsBeforeConnecting_
ReportsExitCode
2/2 killedAsserts the specific exit code surfaces in the exception message.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackReturnsNullTask_Fails
2/2 killedAsserts the specific inner exception type for a misbehaving callback returning a null task.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_NullCallback_Throws
1/1 killedFocused single-assertion guard-clause test using the exact exception type.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_AlreadyCanceled_
DoesNotInvokeCallback
2/2 killedVerifies both the cancellation exception and, via an interlocked counter, that the callback never ran.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CanceledWhileConnecting_
CancelsTheCallbackToken
2/2 killedConfirms both the caller-side cancellation and that the callback's own token observed cancellation.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_ConnectionTimeoutElapses_
FailsWithTimeoutMessage
3/3 killedDistinguishes the connection timeout from the deliberately huge shutdown timeout via message and elapsed-time bound.
A (90–100)new MtpServerClientInProcessTests.
Dispose_ClosesTransportAndAwaitsTheCallback
4/4 killedChecks pre/post completion state, the callback's returned exit code, and the client's reported exit code.
A (90–100)new MtpServerClientInProcessTests.
ShutdownAsync_ClosesTransportAndAwaitsTheCallback_
WithoutBlocking
4/4 killedVerifies await-completion, exit code, a fast follow-up Dispose, and single-teardown count together.
A (90–100)new MtpServerClientInProcessTests.
Dispose_FromANotificationHandler_
DoesNotSelfWaitOnTheReadLoop
1/1 killedTargeted timing assertion guards against a specific re-entrancy deadlock regression.
A (90–100)new MtpServerClientInProcessTests.
Dispose_WhileShutdownAsyncIsInFlight_
WaitsForTheSameTeardown
3/3 killedUses a controlled release gate to prove the racing Dispose genuinely blocks, then confirms a single shared teardown.
A (90–100)new MtpServerClientInProcessTests.
Dispose_IsIdempotent
3/3 killedConfirms exactly one transport close and one completion despite three Dispose calls, plus a fast-return bound.
A (90–100)new MtpServerClientInProcessTests.
Dispose_CallbackIgnoresShutdown_
ReturnsWithinTheDocumentedBound
2/2 killedVerifies both the documented abandonment time bound and the logged "abandoning" diagnostic.
A (90–100)new MtpServerClientInProcessTests.
RunTestsAsync_Canceled_
SendsCancelRequestToTheHostedApplication
2/2 killedConfirms both the client-side cancellation exception and the wire-level cancel notification reaching the fake server.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_MultipleRequestsOnOneSession_
ReuseTheSameConnection
3/3 killedChecks keep-alive negotiation, single-connection reuse count, and total request count together.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_IgnoresEnvironmentVariablesAndWarns
1/1 killedAsserts the specific warning content naming the ignored option.
A (90–100)new MtpServerClientInProcessAcceptanceTests.
InProcessHost_
DiscoversAndRunsItsOwnMSTestNodes_
WithoutStartingAProcess
5/5 killedReal end-to-end embedded-host run asserting build success, exit code, and each marker line the generated app emits.
A (90–100)mod MtpServerClientAcceptanceTests.
DiscoverAndRun_ViaSourcePackageClient_
ReportsExpectedTestNode
1/1 killedNew lines assert the external-process ShutdownAsync/Dispose share a teardown and report an exit code.
A (90–100)mod MtpServerClientSourcePackageConsumerTests.
HostileConsumer_
CompilesAgainstPackedSource
N/ACompile-only guard; added lines correctly extend surface coverage to ShutdownAsync and the in-process launch path.

This advisory comment was generated automatically. Grades are heuristic
and informational — they do not block merging. Suggestions on the Files
changed tab can be applied with one click. Re-run with
/review-tests.

🤖 Automated content by GitHub Copilot. Generated by the Test Reviewer on PR (on open / sync) workflow. · auto · 159.6 AIC · ⌖ 2.87 AIC · ⊞ 16.9K · [◷]( · )

@github-actions

Copy link
Copy Markdown
Contributor

🧵 Parallel-safety audit — PR #10898

Parallelization — audited assemblies:

Test assemblyScopeWorkersAnalyzer coverage
MSTest.Acceptance.IntegrationTestsMethodLevel (default [assembly: Parallelize(Scope = ExecutionScope.MethodLevel, Workers = 0)] in Program.cs)CPU countcoverable once the parallel-safety analyzers ship (attribute-based opt-in)
Microsoft.Testing.Platform.Acceptance.IntegrationTestsMethodLevel (Program.cs)CPU countcoverable once shipped
Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTestsMethodLevel (Program.cs)CPU countcoverable once shipped

This PR did not touch any .runsettings/testconfig.json/Directory.Build.* parallelization config — no assembly's scope changed.

Findings: A (global-state) 0 · B (paths) 0 · C (declaration) 0 · D (over-serialization) 0 — by severity: Critical 0 · High 0 · Warning 0 · Info 0.

No unsafe call sites found in the lines this PR added or modified.

  • MtpServerClientAcceptanceTests.cs — added lines only call client.ShutdownAsync() / assert ServerExitCode; no process-global state touched.
  • MtpServerClientSourcePackageConsumerTests.cs — added lines call into an isolated generated test asset (DriveAsync/DriveInProcessAsync); no shared/relative paths, env vars, culture, or console state mutated at the assembly level (Console.WriteLine here writes to the generated app's own redirected output, not the auditing process).
  • MtpServerClientInProcessAcceptanceTests.cs (new) — uses TestAsset.GenerateAssetAsync, which allocates a unique GUID-suffixed TempDirectory per instance (TestAsset.cs:19-26, throws if a path already exists) — no shared-path collision. CreateChildEnvironment() builds a fresh Dictionary passed only to the spawned child process's environment, not Environment.SetEnvironmentVariable, so it does not mutate this process's state.
  • FakeMtpServer.cs / MtpServerClientInProcessTests.cs (new) — each test constructs its own TcpListener/FakeMtpServer bound to an OS-assigned loopback port (new TcpListener(IPAddress.Loopback, 0)), so there is no fixed-port collision between concurrently-running tests; no static/process-global state is written.

Nothing here implies a testability or smell concern beyond this audit's scope.

Advisory only — heuristic, non-blocking. Re-run with /parallel-audit. This audit answers "is it parallel-safe?"; for testability, smells, or flakiness see the detect-static-dependencies / test-smell-detection / test-anti-patterns analyses.

🤖 Automated content by GitHub Copilot. Generated by the Parallel-safety audit on PR (on open / sync) workflow. · auto · 117.1 AIC · ⌖ 3.53 AIC · ⊞ 24.8K · [◷]( · )

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated content by GitHub Copilot. Generated by the Test Reviewer on PR (on open / sync) workflow. · auto · 159.6 AIC · ⌖ 2.87 AIC · ⊞ 16.9K ·

client.Dispose();

Assert.IsTrue(server.Completion.IsFaulted);
_ = server.Completion.Exception;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test review · Grade B (80–89) — Reads server.Completion.Exception but never asserts on it, so a mutation changing the faulted exception's identity/message would survive.

Assert the captured exception's message/type instead of discarding it after touching .Exception.

Suggested change
_=server.Completion.Exception;
Exception?faultException=server.Completion.Exception?.GetBaseException();
Assert.IsInstanceOfType<InvalidOperationException>(faultException);
Assert.AreEqual("The application failed while shutting down.",faultException!.Message);

? typed
: SerializerUtilities.Deserialize<InitializeRequestArgs>((IDictionary<string, object?>)initialize.Params!);

Assert.AreEqual(isStateful, args.Capabilities.IsStateful, "The in-process path must forward the client's stateful capability unchanged.");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test review · Grade B (80–89) — Only asserts the sent initialize args; a mutation dropping the negotiated round-trip value on client.Capabilities is not caught.

Also assert client.Capabilities.IsStateful reflects the negotiated round-trip value, not only the sent request.

Suggested change
Assert.AreEqual(isStateful,args.Capabilities.IsStateful,"The in-process path must forward the client's stateful capability unchanged.");
Assert.AreEqual(isStateful,args.Capabilities.IsStateful,"The in-process path must forward the client's stateful capability unchanged.");
Assert.AreEqual(isStateful,capabilities.IsStateful,"The negotiated capability the client stores must reflect the server's round trip, not just the sent request.");

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

🤖 Automated review by GitHub Copilot. Generated by the Expert Code Review workflow. To request a follow-up action, reply by tagging @copilot directly.

✅ 22/22 dimensions clean — no findings.

Summary: This PR adds MtpServerClient.LaunchInProcessAsync for embedded hosts (MAUI, Android/iOS) that cannot Process.Start. The implementation is thorough and well-structured:

  • Threading & Concurrency: Shutdown is idempotent via lock + lazy Task, Volatile.Read/Write for cross-thread exit code, Task.Run avoids sync-context deadlocks. No shared mutable state without synchronization.
  • Security & IPC: Loopback TCP only, no command-line injection surface (arguments are array-based), WASM fails fast with PlatformNotSupportedException.
  • Public API: All new API is internal (source-only package). No init accessors. No PublicAPI.Unshipped.txt changes needed.
  • Performance: GetCurrentProcessId() snapshotted once in constructor. No hot-path allocations. WaitBoundedAsync clamps timeouts correctly.
  • Cross-TFM: #if NET8_0_OR_GREATER guard on AcceptTcpClientAsync(CancellationToken). RuntimeInformation used for OS detection on net462.
  • Resource Management: Every disposable (TcpListener, TcpClient, CancellationTokenSource, Process) has cleanup in both success and error paths. Pending accepts are neutralized. ServerCancellation is only disposed when safe.
  • Defensive Coding: Callback exceptions are wrapped as InnerException in MtpServerConnectionClosedException. Null task from callback is caught. Cancel() runs on thread pool to avoid blocking registration.
  • Error Handling: Shared teardown task wrapped in catch-all so it never faults. ObserveFailure prevents UnobservedTaskException. Every teardown helper is non-throwing.
  • Tests: 68 unit tests + acceptance tests covering argument shape, lifecycle, faults, cancellation, timeouts, idempotent disposal, notification-handler disposal, and the real end-to-end in-process path.
  • Documentation: Changelog, protocol intro, PACKAGE.md all updated. XML doc comments are thorough.

The refactoring of shared transport logic into MtpServerConnector and the IMtpServerHost abstraction is clean and prevents the two launch paths from drifting. The existing LaunchAsync(path) path is provably unchanged (same argument string, same failure messages, same teardown order).

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

Review tier: Balanced
Findings: 3 Medium severity

New issues introduced by this change (3)
SeverityFinding
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.csMtpJsonRpcConnection.Dispose() closes the socket and then waits up to its 5-second read-loop…
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.csShutdownAsync writes this nullable property from the teardown worker while callers can read…
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.cs — A canceled callback takes this branch without an inner exception, although the package…
What changed in this PR

Adds in-process launch support to the source-only MTP server-mode client for embedded/mobile hosts, addressing #10890 and enabling scenarios related to #9809.

Changes:

  • Adds callback-based in-process hosting with shared transport setup.
  • Adds bounded asynchronous shutdown and server exit-code reporting.
  • Adds documentation, unit coverage, and real-MSTest acceptance coverage.
FileDescription
MtpServerClientInProcessTests.csTests in-process lifecycle and protocol behavior.
FakeMtpServer.csSupports server-to-client dial-back mode.
MtpServerClientInProcessAcceptanceTests.csExercises a real in-process MSTest application.
MtpServerClientAcceptanceTests.csCovers external-process shutdown and exit code.
MtpServerClientSourcePackageConsumerTests.csExtends source-package compile coverage.
PACKAGE.mdDocuments embedded-host usage and lifecycle.
MtpServerProcess.csAdopts shared host and shutdown abstractions.
MtpServerInProcessHost.csImplements callback hosting and teardown.
MtpServerConnector.csCentralizes listener and transport setup.
MtpServerClientOptions.csAdds the shutdown timeout option.
MtpServerClientExceptions.csSupports preserved inner exceptions.
MtpServerClient.csExposes in-process launch and shutdown.
IMtpServerHost.csDefines common host ownership behavior.
IMtpServerClient.csAdds shutdown and exit-code members.
001-protocol-intro.mdDocuments reference-client launch modes.
Changelog-Platform.mdRecords the new embedded-host capability.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +254 to +258
Connection.Dispose();
SafeDispose(_client, _logger, "Disposing the accepted client socket");
MtpServerConnector.SafeStop(_listener, _logger);

bool stopped = await ShutdownServerAsync(_serverTask, _serverCancellation, _shutdownTimeout, _logger).ConfigureAwait(false);
/// Gets the exit code the hosted application returned, or <see langword="null"/> while it is still
/// running (or when it failed or was abandoned rather than returning one).
/// </summary>
public int? ExitCode { get; private set; }
Comment on lines +311 to +315
if (serverTask.IsCanceled)
{
return new MtpServerConnectionClosedException(
"The in-process Microsoft.Testing.Platform application was canceled before connecting back.");
}
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add in-process launch support to the MTP server-mode client

2 participants

@Evangelink
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add in-process launch support to the MTP server-mode client by Evangelink · Pull Request #10898 · microsoft/testfx · GitHub
Skip to content

Add in-process launch support to the MTP server-mode client - #10898

Open
Amaury Levé (Evangelink) wants to merge 4 commits into
mainfrom
dev/amauryleve/mtp-in-process-client-launch
Open

Add in-process launch support to the MTP server-mode client#10898
Amaury Levé (Evangelink) wants to merge 4 commits into
mainfrom
dev/amauryleve/mtp-in-process-client-launch

Conversation

@Evangelink

Copy link
Copy Markdown
Member

Fixes#10890

Why

MtpServerClient.LaunchAsync(path) owns the loopback listener and starts the test application with Process.Start. That is right for IDE and desktop tooling, but unusable for embedded hosts — MAUI apps, Android/iOS test apps — where the MTP application already runs in the caller's process.

Today those hosts have to reimplement the listener, the --server/host/port arguments, the race between connection and startup failure, the serializer-before-formatter registration order, TcpMessageHandler + MtpJsonRpcConnection construction, and the exit / transport-close / server-completion shutdown dance. That defeats the point of shipping a canonical client and makes it easy to reintroduce bugs this package already solved: partial frame writes, ignored JSON-RPC errors, missing $/cancelRequest, unbounded waits, exception masking.

A concrete consumer is the DeviceRunners MSTest visual-runner work (mattleibow/DeviceRunners#157, #9809).

What

usingIMtpServerClientclient=awaitMtpServerClient.LaunchInProcessAsync(async(serverArgs,token)=>{ITestApplicationBuilderbuilder=awaitTestApplication.CreateBuilderAsync(serverArgs);builder.AddMSTest(()=>testAssemblies);usingITestApplicationapp=awaitbuilder.BuildAsync();returnawaitapp.RunAsync();},options,cancellationToken);awaitclient.InitializeAsync();awaitclient.DiscoverTestsAsync();awaitclient.RunTestsAsync();awaitclient.ExitAsync();awaitclient.ShutdownAsync();

The caller supplies only "how to run the application". The client keeps everything else: it binds the listener, generates the complete argument array (--server jsonrpc --client-host 127.0.0.1 --client-port <port> --no-banner), races the connect, builds the transport, and owns a bounded shutdown.

New API on the injected (still internal) surface:

MemberPurpose
MtpServerClient.LaunchInProcessAsync(callback, options, ct)The embedded-host launch path. Async only — blocking the launching thread can deadlock the application being launched.
IMtpServerClient.ShutdownAsync()Non-blocking teardown.
IMtpServerClient.ServerExitCodeThe value the application returned.
MtpServerClientOptions.ServerShutdownTimeoutGraceful shutdown bound (default 30s).

Internally, the transport setup was extracted from MtpServerProcess into MtpServerConnector, and both launch paths now sit behind IMtpServerHost, so they cannot drift.

Behavior worth reviewing

Ownership and shutdown. Both Dispose() and ShutdownAsync() join one lazily created shared teardown task, on both hosts. A Dispose that follows or races ShutdownAsync therefore returns only once the server has actually stopped, rather than reporting success while teardown is still running. Teardown runs on the thread pool, so ShutdownAsync never blocks and the synchronous Dispose cannot deadlock against a UI-thread continuation. Task.Run captures the execution context, so the connection's read-loop AsyncLocal marker still flows and disposing from a notification handler does not self-wait (covered by a test asserting < 4s against the connection's 5s read-loop timeout).

Bounded, and actually bounded. Teardown waits ServerShutdownTimeout, then cancels the callback's token, then a fixed 5s grace, then abandons and logs. CancellationTokenSource.Cancel() runs registrations synchronously, so the cancellation is started separately — otherwise a blocking caller registration would prevent the grace from ever starting and make the "bounded" wait unbounded. A failed launch skips the graceful wait entirely: nothing is connected, so there is no transport closure for the callback to observe.

Exception preservation. A callback that throws, is canceled, or returns before dialing back surfaces as MtpServerConnectionClosedException with the original as InnerException, instead of a misleading connection timeout. Every teardown helper is non-throwing, and the shared teardown task is wrapped so it can never fault — a faulted shared task would throw from every later disposal.

LaunchAsync(path) is unchanged. Verified against main: identical argument string, failure messages, stderr capture, exit fast-fail and teardown order. One earlier revision of this branch added a grace period to the accept race; review showed it only traded a precise failure for a vague one, so it was removed and the external path is now provably unchanged.

Browser/WASM. Both paths are loopback TCP. LaunchInProcessAsync fails fast with PlatformNotSupportedException there; this does not enable WASM hosting, and the docs say so.

Tests

  • 68 unit tests (net8.0) / 63 (net462, Jsonite path). New coverage: argument array shape; initialize → discover → run → exit; callback faulting synchronously and asynchronously; callback exiting with a code; null task; pre-canceled launch not invoking the callback; cancellation during connect canceling the callback token; connection timeout bounded by the grace rather than ServerShutdownTimeout; ShutdownAsync; ServerExitCode; disposal awaiting the callback; Dispose racing an in-flight ShutdownAsync; Dispose from a notification handler; idempotent disposal; callback faulting during shutdown; unresponsive callback abandoned within the bound; $/cancelRequest; stateful on/off; multi-request single connection; EnvironmentVariables ignored and warned. Stressed 6×/5× consecutively for flakiness.
  • New acceptance test where a single generated process is simultaneously the embedded host (compiling the packed source-only package) and a real MTP TestApplication with real MSTest over a real [TestClass] — discovering and running its own test over JSON-RPC with no Process.Start anywhere. It also asserts the server's reported process id equals its own.
  • Existing external-process acceptance test extended to cover ShutdownAsync + ServerExitCode; hostile-consumer compile oracle extended to bind the new API on net462/netstandard2.0/net5.0–net8.0.
  • Full regression: 13 platform source-package/consumer acceptance, 4 MSTest acceptance, 13 platform ServerTests. build.cmd -pack clean, 0 warnings.

Review history

Four independent review rounds (MTP/MSTest expert reviewer, a design reviewer, and two correctness reviewers) produced 3 major, 10 moderate and 10 minor findings, all addressed. Several were real bugs the tests then locked in — notably Process.ExitCode being unreadable after Process.Dispose(), a TcpListener socket leak when Start() failed, and a teardown path that could throw from a contract documented never to throw.

Open design questions

  1. No IAsyncDisposable. netstandard2.0/net462 would need Microsoft.Bcl.AsyncInterfaces, breaking the package's dependency-free promise. ShutdownAsync() is the substitute. Happy to revisit if the dependency is acceptable.
  2. MtpServerClientOptions is mode-mixed.EnvironmentVariables is external-process only; ServerShutdownTimeout is in-process only. Nesting per-transport options would age better, but EnvironmentVariables already shipped at the top level, so it cannot be done non-breakingly now.
  3. The 5s cancellation grace is a fixed constant, not an option.
  4. The two paths still build different argument shapes — deliberate, to keep the shipped external command line byte-identical. Commented at the call site.
  5. No ConnectAsync(TcpClient/Stream, options) factory — the existing MtpServerClient(MtpJsonRpcConnection, options) constructor already covers "wrap an existing transport", and the guidance is to minimize injected surface. Easy to add if wanted.

Embedded hosts such as MAUI or Android/iOS test apps cannot spawn a child
process, so `MtpServerClient.LaunchAsync(path)` was unusable for them and they
had to reimplement the listener, the server-mode arguments, the connect race,
the serializer/formatter ordering, the transport and the shutdown coordination
by hand -- reintroducing the bugs the canonical client already solves.
Add `MtpServerClient.LaunchInProcessAsync(callback, options, cancellationToken)`.
The client keeps ownership of everything except "how to run the application":
it binds the loopback listener, generates the complete server-mode argument
array, races the connect against callback failure/completion, caller
cancellation and the connection timeout, and owns a bounded shutdown that
closes the transport, then cancels the callback token, then abandons it rather
than hanging the caller. A callback failure before connection is surfaced as
`MtpServerConnectionClosedException` with the caller's exception preserved as
the inner exception, and teardown failures are only logged so they can never
replace the primary failure.
The shared transport setup is extracted from `MtpServerProcess` into
`MtpServerConnector` and both launch paths now flow through `IMtpServerHost`,
so the external-process behavior is unchanged while the two paths cannot drift.
The one behavior change is an improvement shared by both: when the server is
seen to have stopped, a still-pending accept gets a bounded final grace so a
connection established just before the stop is not discarded in favor of a
misleading "stopped before connecting back" failure.
The path is loopback TCP, so it fails fast with `PlatformNotSupportedException`
on browser/WASM; it does not enable WASM hosting.
Fixes#10890
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two independent reviews of the in-process launch path raised the same top
finding: `Dispose()` waits for the hosted application synchronously on the
calling thread, and on the very platforms this feature targets (MAUI, Android,
iOS) a multi-second block on the UI thread trips the ANR / watchdog. Add
`IMtpServerClient.ShutdownAsync()` (and `IMtpServerHost.ShutdownAsync`) so the
same teardown can be awaited instead. `IAsyncDisposable` stays rejected --
netstandard2.0 and net462 would need `Microsoft.Bcl.AsyncInterfaces`, which
breaks the package's dependency-free promise -- but rejecting the interface is
not a reason to have no asynchronous shutdown at all. `Dispose()` remains, is
still idempotent, and returns immediately after `ShutdownAsync`.
Remove the accept grace period. The previous commit let a still-pending accept
win for ~100ms after the server was seen to have stopped, on the theory that a
connection established just before the stop should not be discarded. That
theory does not hold: if the server has stopped, the socket belongs to a dead
peer, so the grace only traded a precise failure (exit code plus captured
stderr) for a generic connection-closed error on the first request, and its
uncancellable delay could let the server failure beat a concurrent caller
cancellation. Dropping it also makes the external-process path provably
unchanged, so the changelog no longer needs a `Changed` entry.
Other review fixes:
* Expose `IMtpServerClient.ServerExitCode`. The callback signature already
demanded a `Task<int>`, but after a successful session the value was
unreachable, so an embedded host whose `Main` must return it had to capture
it in a closure.
* Do not dispose the server's `CancellationTokenSource` when the callback was
abandoned while still running: it holds the token, and `token.WaitHandle` or
`CreateLinkedTokenSource` would then throw `ObjectDisposedException` inside
the caller's own code.
* Skip the graceful wait entirely on a failed launch. Nothing is connected, so
there is no transport closure for the callback to observe; only the fixed
cancellation grace applies and an unwinding caller no longer pays
`ServerShutdownTimeout`.
* Clamp bounded waits instead of trusting the caller's `TimeSpan`. A negative
value (or `Timeout.InfiniteTimeSpan`) made `Task.Delay` throw from a path
documented never to throw; an oversized one is capped to the largest delay
.NET Framework accepts.
* Move `TcpListener.Start()` inside the cleanup `try`: it creates the socket
before binding, so a bind failure leaked it because the caller never received
a listener it could stop.
* Log a late failure from an abandoned callback rather than only observing it.
* Cache the in-process host's `ProcessId` instead of allocating a `Process` per
property read.
* Document that `Dispose()` blocks, that cancellation is bounded rather than
immediate, and why the two launch paths still build different argument
shapes; fix the shadowed `cancellationToken` in the PACKAGE.md sample.
Tests: cover `ShutdownAsync`, `ServerExitCode`, and fail-fast on a server that
stops without connecting; make `Dispose_IsIdempotent` able to fail by asserting
on observed transport closes rather than callback invocations; replace the
hand-rolled throws helper with `Assert.ThrowsExactlyAsync`; tighten the
shutdown-bound assertion; and stop leaking `Process` handles.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A third review round found three ways the teardown contract did not hold up
under concurrency.
`Dispose()` and `ShutdownAsync()` each had their own `Interlocked.Exchange`
guard, so whichever call lost the race returned while teardown was still
running -- reporting to its caller that the application had stopped when it had
not. Replace both guards with one lazily started, shared teardown task: every
entry point now joins the same task, so `Dispose` after (or racing)
`ShutdownAsync` returns only once the application has actually stopped, and the
whole thing stays idempotent. `MtpServerClient` no longer keeps its own guard
either; it delegates to the host, whose teardown is the single source of truth.
`ShutdownAsync()` could still block the very thread it exists to protect: it
closed the transport before its first await, and closing the connection waits
up to five seconds for the read loop. The shared task is started with
`Task.Run`, so the whole teardown -- transport close included -- runs on the
thread pool and `ShutdownAsync` returns immediately. `MtpServerProcess`
likewise now runs its (bounded but synchronous) kill off the calling thread
rather than pretending to be async while blocking.
`CancellationTokenSource.Cancel()` executes registrations synchronously on the
calling thread, so a caller registration that blocked would prevent the
five-second cancellation grace from ever starting and make the "bounded"
shutdown unbounded. Start the cancellation separately and begin the grace
regardless. When a registration is still executing once the callback has
finished, the source is reported as unsafe to dispose for the same reason an
abandoned callback is: leaking one `CancellationTokenSource` beats a
use-after-dispose inside the caller's code.
Adds a test that a `Dispose` racing an in-flight `ShutdownAsync` blocks until
the shared teardown completes, and that both share one teardown.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… fix ExitCode
A fourth review round found that only the in-process host had been converted to
the shared-teardown design. `MtpServerProcess` kept its old early-return guard,
so on the external-process path a `Dispose()` that followed or raced
`ShutdownAsync()` returned while `SafeKill`'s bounded `WaitForExit` was still
running on another thread -- losing exactly the guarantee that wait exists for
(a caller may delete the application directory immediately after disposal), and
making `await ShutdownAsync()` report that the server had stopped when it had
not. That contradicted the comment the previous commit added to
`MtpServerClient.Dispose`, which claimed both paths joined an in-flight
teardown. Convert `MtpServerProcess` to the same lazily created shared task, so
the contract is now uniform across both implementations of `IMtpServerHost`,
and reconcile the interface docs, which previously described the opposite rule.
Adding real coverage for `ShutdownAsync` on the external-process path then
surfaced a genuine bug: `MtpServerProcess.ExitCode` was always `null` after
teardown, because a `Process` cannot be queried once disposed. Capture the exit
code during teardown instead -- before the kill, so an application that already
exited on its own reports its real code rather than the kill's, and before
`Process.Dispose()`, after which nothing is readable.
Also fixes a race in the in-process test fixture that made the suite flaky on
net462: the callback published its `FakeMtpServer` only after its own connect
call returned, but the client's accept can complete first, so a test could
reach `Value` before the callback had set it. The fixture now exposes a
`Connected` signal the launch helper awaits. 6/6 clean net462 runs and 5/5
clean net8.0 runs afterwards.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI balanced review requested due to automatic review settings August 31, 2026 19:22
Comment on lines +182 to +193
if (serverTask is not null)
{
// 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.
if (!await ShutdownServerAsync(serverTask, serverCancellation!, TimeSpan.Zero, logger).ConfigureAwait(false))
{
// 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;
}
}
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Expert test review — PR #10898

GradeTestMutationNotesHow to improve
B (80–89)new MtpServerClientInProcessTests.
Dispose_CallbackFaultsDuringShutdown_
DoesNotThrow
2/3 killedReads server.Completion.Exception but never asserts on it, so a mutation changing the faulted exception's identity/message survives.Assert the captured exception's message/type instead of discarding it after touching .Exception.
B (80–89)new MtpServerClientInProcessTests.
LaunchInProcessAsync_HonorsTheStatefulOption
3/4 killedOnly asserts the sent initialize args; a mutation dropping the negotiated round-trip value on client.Capabilities is not caught.Also assert client.Capabilities.IsStateful reflects the negotiated round-trip value, not only the sent request.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_PassesCompleteServerModeArguments
5/5 killedVerifies exact ordered arguments, dynamic port and fixed count; strong regression guard for the generated argument array.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_DrivesInitializeDiscoverRunAndExit
6/6 killedEnd-to-end drive through initialize/discover/run/exit with distinct assertions per state transition.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackFaultsBeforeConnecting_
PreservesCallbackException
2/2 killedAsserts the exact exception instance is preserved as inner exception, not just its type.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackFaultsAsynchronouslyBeforeConnecting_
PreservesCallbackException
2/2 killedCovers the async-throw variant with the same identity assertion as the sync case.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackExitsWithoutConnecting_
FailsFastInsteadOfWaitingOutTheTimeout
3/3 killedChecks both the reported exit code in the message and a timing bound guarding against a slow-timeout regression.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackExitsBeforeConnecting_
ReportsExitCode
2/2 killedAsserts the specific exit code surfaces in the exception message.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackReturnsNullTask_Fails
2/2 killedAsserts the specific inner exception type for a misbehaving callback returning a null task.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_NullCallback_Throws
1/1 killedFocused single-assertion guard-clause test using the exact exception type.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_AlreadyCanceled_
DoesNotInvokeCallback
2/2 killedVerifies both the cancellation exception and, via an interlocked counter, that the callback never ran.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CanceledWhileConnecting_
CancelsTheCallbackToken
2/2 killedConfirms both the caller-side cancellation and that the callback's own token observed cancellation.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_ConnectionTimeoutElapses_
FailsWithTimeoutMessage
3/3 killedDistinguishes the connection timeout from the deliberately huge shutdown timeout via message and elapsed-time bound.
A (90–100)new MtpServerClientInProcessTests.
Dispose_ClosesTransportAndAwaitsTheCallback
4/4 killedChecks pre/post completion state, the callback's returned exit code, and the client's reported exit code.
A (90–100)new MtpServerClientInProcessTests.
ShutdownAsync_ClosesTransportAndAwaitsTheCallback_
WithoutBlocking
4/4 killedVerifies await-completion, exit code, a fast follow-up Dispose, and single-teardown count together.
A (90–100)new MtpServerClientInProcessTests.
Dispose_FromANotificationHandler_
DoesNotSelfWaitOnTheReadLoop
1/1 killedTargeted timing assertion guards against a specific re-entrancy deadlock regression.
A (90–100)new MtpServerClientInProcessTests.
Dispose_WhileShutdownAsyncIsInFlight_
WaitsForTheSameTeardown
3/3 killedUses a controlled release gate to prove the racing Dispose genuinely blocks, then confirms a single shared teardown.
A (90–100)new MtpServerClientInProcessTests.
Dispose_IsIdempotent
3/3 killedConfirms exactly one transport close and one completion despite three Dispose calls, plus a fast-return bound.
A (90–100)new MtpServerClientInProcessTests.
Dispose_CallbackIgnoresShutdown_
ReturnsWithinTheDocumentedBound
2/2 killedVerifies both the documented abandonment time bound and the logged "abandoning" diagnostic.
A (90–100)new MtpServerClientInProcessTests.
RunTestsAsync_Canceled_
SendsCancelRequestToTheHostedApplication
2/2 killedConfirms both the client-side cancellation exception and the wire-level cancel notification reaching the fake server.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_MultipleRequestsOnOneSession_
ReuseTheSameConnection
3/3 killedChecks keep-alive negotiation, single-connection reuse count, and total request count together.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_IgnoresEnvironmentVariablesAndWarns
1/1 killedAsserts the specific warning content naming the ignored option.
A (90–100)new MtpServerClientInProcessAcceptanceTests.
InProcessHost_
DiscoversAndRunsItsOwnMSTestNodes_
WithoutStartingAProcess
5/5 killedReal end-to-end embedded-host run asserting build success, exit code, and each marker line the generated app emits.
A (90–100)mod MtpServerClientAcceptanceTests.
DiscoverAndRun_ViaSourcePackageClient_
ReportsExpectedTestNode
1/1 killedNew lines assert the external-process ShutdownAsync/Dispose share a teardown and report an exit code.
A (90–100)mod MtpServerClientSourcePackageConsumerTests.
HostileConsumer_
CompilesAgainstPackedSource
N/ACompile-only guard; added lines correctly extend surface coverage to ShutdownAsync and the in-process launch path.

This advisory comment was generated automatically. Grades are heuristic
and informational — they do not block merging. Suggestions on the Files
changed tab can be applied with one click. Re-run with
/review-tests.

🤖 Automated content by GitHub Copilot. Generated by the Test Reviewer on PR (on open / sync) workflow. · auto · 159.6 AIC · ⌖ 2.87 AIC · ⊞ 16.9K · [◷]( · )

@github-actions

Copy link
Copy Markdown
Contributor

🧵 Parallel-safety audit — PR #10898

Parallelization — audited assemblies:

Test assemblyScopeWorkersAnalyzer coverage
MSTest.Acceptance.IntegrationTestsMethodLevel (default [assembly: Parallelize(Scope = ExecutionScope.MethodLevel, Workers = 0)] in Program.cs)CPU countcoverable once the parallel-safety analyzers ship (attribute-based opt-in)
Microsoft.Testing.Platform.Acceptance.IntegrationTestsMethodLevel (Program.cs)CPU countcoverable once shipped
Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTestsMethodLevel (Program.cs)CPU countcoverable once shipped

This PR did not touch any .runsettings/testconfig.json/Directory.Build.* parallelization config — no assembly's scope changed.

Findings: A (global-state) 0 · B (paths) 0 · C (declaration) 0 · D (over-serialization) 0 — by severity: Critical 0 · High 0 · Warning 0 · Info 0.

No unsafe call sites found in the lines this PR added or modified.

  • MtpServerClientAcceptanceTests.cs — added lines only call client.ShutdownAsync() / assert ServerExitCode; no process-global state touched.
  • MtpServerClientSourcePackageConsumerTests.cs — added lines call into an isolated generated test asset (DriveAsync/DriveInProcessAsync); no shared/relative paths, env vars, culture, or console state mutated at the assembly level (Console.WriteLine here writes to the generated app's own redirected output, not the auditing process).
  • MtpServerClientInProcessAcceptanceTests.cs (new) — uses TestAsset.GenerateAssetAsync, which allocates a unique GUID-suffixed TempDirectory per instance (TestAsset.cs:19-26, throws if a path already exists) — no shared-path collision. CreateChildEnvironment() builds a fresh Dictionary passed only to the spawned child process's environment, not Environment.SetEnvironmentVariable, so it does not mutate this process's state.
  • FakeMtpServer.cs / MtpServerClientInProcessTests.cs (new) — each test constructs its own TcpListener/FakeMtpServer bound to an OS-assigned loopback port (new TcpListener(IPAddress.Loopback, 0)), so there is no fixed-port collision between concurrently-running tests; no static/process-global state is written.

Nothing here implies a testability or smell concern beyond this audit's scope.

Advisory only — heuristic, non-blocking. Re-run with /parallel-audit. This audit answers "is it parallel-safe?"; for testability, smells, or flakiness see the detect-static-dependencies / test-smell-detection / test-anti-patterns analyses.

🤖 Automated content by GitHub Copilot. Generated by the Parallel-safety audit on PR (on open / sync) workflow. · auto · 117.1 AIC · ⌖ 3.53 AIC · ⊞ 24.8K · [◷]( · )

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated content by GitHub Copilot. Generated by the Test Reviewer on PR (on open / sync) workflow. · auto · 159.6 AIC · ⌖ 2.87 AIC · ⊞ 16.9K ·

client.Dispose();

Assert.IsTrue(server.Completion.IsFaulted);
_ = server.Completion.Exception;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test review · Grade B (80–89) — Reads server.Completion.Exception but never asserts on it, so a mutation changing the faulted exception's identity/message would survive.

Assert the captured exception's message/type instead of discarding it after touching .Exception.

Suggested change
_=server.Completion.Exception;
Exception?faultException=server.Completion.Exception?.GetBaseException();
Assert.IsInstanceOfType<InvalidOperationException>(faultException);
Assert.AreEqual("The application failed while shutting down.",faultException!.Message);

? typed
: SerializerUtilities.Deserialize<InitializeRequestArgs>((IDictionary<string, object?>)initialize.Params!);

Assert.AreEqual(isStateful, args.Capabilities.IsStateful, "The in-process path must forward the client's stateful capability unchanged.");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test review · Grade B (80–89) — Only asserts the sent initialize args; a mutation dropping the negotiated round-trip value on client.Capabilities is not caught.

Also assert client.Capabilities.IsStateful reflects the negotiated round-trip value, not only the sent request.

Suggested change
Assert.AreEqual(isStateful,args.Capabilities.IsStateful,"The in-process path must forward the client's stateful capability unchanged.");
Assert.AreEqual(isStateful,args.Capabilities.IsStateful,"The in-process path must forward the client's stateful capability unchanged.");
Assert.AreEqual(isStateful,capabilities.IsStateful,"The negotiated capability the client stores must reflect the server's round trip, not just the sent request.");

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

🤖 Automated review by GitHub Copilot. Generated by the Expert Code Review workflow. To request a follow-up action, reply by tagging @copilot directly.

✅ 22/22 dimensions clean — no findings.

Summary: This PR adds MtpServerClient.LaunchInProcessAsync for embedded hosts (MAUI, Android/iOS) that cannot Process.Start. The implementation is thorough and well-structured:

  • Threading & Concurrency: Shutdown is idempotent via lock + lazy Task, Volatile.Read/Write for cross-thread exit code, Task.Run avoids sync-context deadlocks. No shared mutable state without synchronization.
  • Security & IPC: Loopback TCP only, no command-line injection surface (arguments are array-based), WASM fails fast with PlatformNotSupportedException.
  • Public API: All new API is internal (source-only package). No init accessors. No PublicAPI.Unshipped.txt changes needed.
  • Performance: GetCurrentProcessId() snapshotted once in constructor. No hot-path allocations. WaitBoundedAsync clamps timeouts correctly.
  • Cross-TFM: #if NET8_0_OR_GREATER guard on AcceptTcpClientAsync(CancellationToken). RuntimeInformation used for OS detection on net462.
  • Resource Management: Every disposable (TcpListener, TcpClient, CancellationTokenSource, Process) has cleanup in both success and error paths. Pending accepts are neutralized. ServerCancellation is only disposed when safe.
  • Defensive Coding: Callback exceptions are wrapped as InnerException in MtpServerConnectionClosedException. Null task from callback is caught. Cancel() runs on thread pool to avoid blocking registration.
  • Error Handling: Shared teardown task wrapped in catch-all so it never faults. ObserveFailure prevents UnobservedTaskException. Every teardown helper is non-throwing.
  • Tests: 68 unit tests + acceptance tests covering argument shape, lifecycle, faults, cancellation, timeouts, idempotent disposal, notification-handler disposal, and the real end-to-end in-process path.
  • Documentation: Changelog, protocol intro, PACKAGE.md all updated. XML doc comments are thorough.

The refactoring of shared transport logic into MtpServerConnector and the IMtpServerHost abstraction is clean and prevents the two launch paths from drifting. The existing LaunchAsync(path) path is provably unchanged (same argument string, same failure messages, same teardown order).

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

Review tier: Balanced
Findings: 3 Medium severity

New issues introduced by this change (3)
SeverityFinding
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.csMtpJsonRpcConnection.Dispose() closes the socket and then waits up to its 5-second read-loop…
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.csShutdownAsync writes this nullable property from the teardown worker while callers can read…
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.cs — A canceled callback takes this branch without an inner exception, although the package…
What changed in this PR

Adds in-process launch support to the source-only MTP server-mode client for embedded/mobile hosts, addressing #10890 and enabling scenarios related to #9809.

Changes:

  • Adds callback-based in-process hosting with shared transport setup.
  • Adds bounded asynchronous shutdown and server exit-code reporting.
  • Adds documentation, unit coverage, and real-MSTest acceptance coverage.
FileDescription
MtpServerClientInProcessTests.csTests in-process lifecycle and protocol behavior.
FakeMtpServer.csSupports server-to-client dial-back mode.
MtpServerClientInProcessAcceptanceTests.csExercises a real in-process MSTest application.
MtpServerClientAcceptanceTests.csCovers external-process shutdown and exit code.
MtpServerClientSourcePackageConsumerTests.csExtends source-package compile coverage.
PACKAGE.mdDocuments embedded-host usage and lifecycle.
MtpServerProcess.csAdopts shared host and shutdown abstractions.
MtpServerInProcessHost.csImplements callback hosting and teardown.
MtpServerConnector.csCentralizes listener and transport setup.
MtpServerClientOptions.csAdds the shutdown timeout option.
MtpServerClientExceptions.csSupports preserved inner exceptions.
MtpServerClient.csExposes in-process launch and shutdown.
IMtpServerHost.csDefines common host ownership behavior.
IMtpServerClient.csAdds shutdown and exit-code members.
001-protocol-intro.mdDocuments reference-client launch modes.
Changelog-Platform.mdRecords the new embedded-host capability.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +254 to +258
Connection.Dispose();
SafeDispose(_client, _logger, "Disposing the accepted client socket");
MtpServerConnector.SafeStop(_listener, _logger);

bool stopped = await ShutdownServerAsync(_serverTask, _serverCancellation, _shutdownTimeout, _logger).ConfigureAwait(false);
/// Gets the exit code the hosted application returned, or <see langword="null"/> while it is still
/// running (or when it failed or was abandoned rather than returning one).
/// </summary>
public int? ExitCode { get; private set; }
Comment on lines +311 to +315
if (serverTask.IsCanceled)
{
return new MtpServerConnectionClosedException(
"The in-process Microsoft.Testing.Platform application was canceled before connecting back.");
}
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add in-process launch support to the MTP server-mode client

2 participants

@Evangelink
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Add in-process launch support to the MTP server-mode client by Evangelink · Pull Request #10898 · microsoft/testfx · GitHub
Skip to content

Add in-process launch support to the MTP server-mode client - #10898

Open
Amaury Levé (Evangelink) wants to merge 4 commits into
mainfrom
dev/amauryleve/mtp-in-process-client-launch
Open

Add in-process launch support to the MTP server-mode client#10898
Amaury Levé (Evangelink) wants to merge 4 commits into
mainfrom
dev/amauryleve/mtp-in-process-client-launch

Conversation

@Evangelink

Copy link
Copy Markdown
Member

Fixes#10890

Why

MtpServerClient.LaunchAsync(path) owns the loopback listener and starts the test application with Process.Start. That is right for IDE and desktop tooling, but unusable for embedded hosts — MAUI apps, Android/iOS test apps — where the MTP application already runs in the caller's process.

Today those hosts have to reimplement the listener, the --server/host/port arguments, the race between connection and startup failure, the serializer-before-formatter registration order, TcpMessageHandler + MtpJsonRpcConnection construction, and the exit / transport-close / server-completion shutdown dance. That defeats the point of shipping a canonical client and makes it easy to reintroduce bugs this package already solved: partial frame writes, ignored JSON-RPC errors, missing $/cancelRequest, unbounded waits, exception masking.

A concrete consumer is the DeviceRunners MSTest visual-runner work (mattleibow/DeviceRunners#157, #9809).

What

usingIMtpServerClientclient=awaitMtpServerClient.LaunchInProcessAsync(async(serverArgs,token)=>{ITestApplicationBuilderbuilder=awaitTestApplication.CreateBuilderAsync(serverArgs);builder.AddMSTest(()=>testAssemblies);usingITestApplicationapp=awaitbuilder.BuildAsync();returnawaitapp.RunAsync();},options,cancellationToken);awaitclient.InitializeAsync();awaitclient.DiscoverTestsAsync();awaitclient.RunTestsAsync();awaitclient.ExitAsync();awaitclient.ShutdownAsync();

The caller supplies only "how to run the application". The client keeps everything else: it binds the listener, generates the complete argument array (--server jsonrpc --client-host 127.0.0.1 --client-port <port> --no-banner), races the connect, builds the transport, and owns a bounded shutdown.

New API on the injected (still internal) surface:

MemberPurpose
MtpServerClient.LaunchInProcessAsync(callback, options, ct)The embedded-host launch path. Async only — blocking the launching thread can deadlock the application being launched.
IMtpServerClient.ShutdownAsync()Non-blocking teardown.
IMtpServerClient.ServerExitCodeThe value the application returned.
MtpServerClientOptions.ServerShutdownTimeoutGraceful shutdown bound (default 30s).

Internally, the transport setup was extracted from MtpServerProcess into MtpServerConnector, and both launch paths now sit behind IMtpServerHost, so they cannot drift.

Behavior worth reviewing

Ownership and shutdown. Both Dispose() and ShutdownAsync() join one lazily created shared teardown task, on both hosts. A Dispose that follows or races ShutdownAsync therefore returns only once the server has actually stopped, rather than reporting success while teardown is still running. Teardown runs on the thread pool, so ShutdownAsync never blocks and the synchronous Dispose cannot deadlock against a UI-thread continuation. Task.Run captures the execution context, so the connection's read-loop AsyncLocal marker still flows and disposing from a notification handler does not self-wait (covered by a test asserting < 4s against the connection's 5s read-loop timeout).

Bounded, and actually bounded. Teardown waits ServerShutdownTimeout, then cancels the callback's token, then a fixed 5s grace, then abandons and logs. CancellationTokenSource.Cancel() runs registrations synchronously, so the cancellation is started separately — otherwise a blocking caller registration would prevent the grace from ever starting and make the "bounded" wait unbounded. A failed launch skips the graceful wait entirely: nothing is connected, so there is no transport closure for the callback to observe.

Exception preservation. A callback that throws, is canceled, or returns before dialing back surfaces as MtpServerConnectionClosedException with the original as InnerException, instead of a misleading connection timeout. Every teardown helper is non-throwing, and the shared teardown task is wrapped so it can never fault — a faulted shared task would throw from every later disposal.

LaunchAsync(path) is unchanged. Verified against main: identical argument string, failure messages, stderr capture, exit fast-fail and teardown order. One earlier revision of this branch added a grace period to the accept race; review showed it only traded a precise failure for a vague one, so it was removed and the external path is now provably unchanged.

Browser/WASM. Both paths are loopback TCP. LaunchInProcessAsync fails fast with PlatformNotSupportedException there; this does not enable WASM hosting, and the docs say so.

Tests

  • 68 unit tests (net8.0) / 63 (net462, Jsonite path). New coverage: argument array shape; initialize → discover → run → exit; callback faulting synchronously and asynchronously; callback exiting with a code; null task; pre-canceled launch not invoking the callback; cancellation during connect canceling the callback token; connection timeout bounded by the grace rather than ServerShutdownTimeout; ShutdownAsync; ServerExitCode; disposal awaiting the callback; Dispose racing an in-flight ShutdownAsync; Dispose from a notification handler; idempotent disposal; callback faulting during shutdown; unresponsive callback abandoned within the bound; $/cancelRequest; stateful on/off; multi-request single connection; EnvironmentVariables ignored and warned. Stressed 6×/5× consecutively for flakiness.
  • New acceptance test where a single generated process is simultaneously the embedded host (compiling the packed source-only package) and a real MTP TestApplication with real MSTest over a real [TestClass] — discovering and running its own test over JSON-RPC with no Process.Start anywhere. It also asserts the server's reported process id equals its own.
  • Existing external-process acceptance test extended to cover ShutdownAsync + ServerExitCode; hostile-consumer compile oracle extended to bind the new API on net462/netstandard2.0/net5.0–net8.0.
  • Full regression: 13 platform source-package/consumer acceptance, 4 MSTest acceptance, 13 platform ServerTests. build.cmd -pack clean, 0 warnings.

Review history

Four independent review rounds (MTP/MSTest expert reviewer, a design reviewer, and two correctness reviewers) produced 3 major, 10 moderate and 10 minor findings, all addressed. Several were real bugs the tests then locked in — notably Process.ExitCode being unreadable after Process.Dispose(), a TcpListener socket leak when Start() failed, and a teardown path that could throw from a contract documented never to throw.

Open design questions

  1. No IAsyncDisposable. netstandard2.0/net462 would need Microsoft.Bcl.AsyncInterfaces, breaking the package's dependency-free promise. ShutdownAsync() is the substitute. Happy to revisit if the dependency is acceptable.
  2. MtpServerClientOptions is mode-mixed.EnvironmentVariables is external-process only; ServerShutdownTimeout is in-process only. Nesting per-transport options would age better, but EnvironmentVariables already shipped at the top level, so it cannot be done non-breakingly now.
  3. The 5s cancellation grace is a fixed constant, not an option.
  4. The two paths still build different argument shapes — deliberate, to keep the shipped external command line byte-identical. Commented at the call site.
  5. No ConnectAsync(TcpClient/Stream, options) factory — the existing MtpServerClient(MtpJsonRpcConnection, options) constructor already covers "wrap an existing transport", and the guidance is to minimize injected surface. Easy to add if wanted.

Embedded hosts such as MAUI or Android/iOS test apps cannot spawn a child
process, so `MtpServerClient.LaunchAsync(path)` was unusable for them and they
had to reimplement the listener, the server-mode arguments, the connect race,
the serializer/formatter ordering, the transport and the shutdown coordination
by hand -- reintroducing the bugs the canonical client already solves.
Add `MtpServerClient.LaunchInProcessAsync(callback, options, cancellationToken)`.
The client keeps ownership of everything except "how to run the application":
it binds the loopback listener, generates the complete server-mode argument
array, races the connect against callback failure/completion, caller
cancellation and the connection timeout, and owns a bounded shutdown that
closes the transport, then cancels the callback token, then abandons it rather
than hanging the caller. A callback failure before connection is surfaced as
`MtpServerConnectionClosedException` with the caller's exception preserved as
the inner exception, and teardown failures are only logged so they can never
replace the primary failure.
The shared transport setup is extracted from `MtpServerProcess` into
`MtpServerConnector` and both launch paths now flow through `IMtpServerHost`,
so the external-process behavior is unchanged while the two paths cannot drift.
The one behavior change is an improvement shared by both: when the server is
seen to have stopped, a still-pending accept gets a bounded final grace so a
connection established just before the stop is not discarded in favor of a
misleading "stopped before connecting back" failure.
The path is loopback TCP, so it fails fast with `PlatformNotSupportedException`
on browser/WASM; it does not enable WASM hosting.
Fixes#10890
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two independent reviews of the in-process launch path raised the same top
finding: `Dispose()` waits for the hosted application synchronously on the
calling thread, and on the very platforms this feature targets (MAUI, Android,
iOS) a multi-second block on the UI thread trips the ANR / watchdog. Add
`IMtpServerClient.ShutdownAsync()` (and `IMtpServerHost.ShutdownAsync`) so the
same teardown can be awaited instead. `IAsyncDisposable` stays rejected --
netstandard2.0 and net462 would need `Microsoft.Bcl.AsyncInterfaces`, which
breaks the package's dependency-free promise -- but rejecting the interface is
not a reason to have no asynchronous shutdown at all. `Dispose()` remains, is
still idempotent, and returns immediately after `ShutdownAsync`.
Remove the accept grace period. The previous commit let a still-pending accept
win for ~100ms after the server was seen to have stopped, on the theory that a
connection established just before the stop should not be discarded. That
theory does not hold: if the server has stopped, the socket belongs to a dead
peer, so the grace only traded a precise failure (exit code plus captured
stderr) for a generic connection-closed error on the first request, and its
uncancellable delay could let the server failure beat a concurrent caller
cancellation. Dropping it also makes the external-process path provably
unchanged, so the changelog no longer needs a `Changed` entry.
Other review fixes:
* Expose `IMtpServerClient.ServerExitCode`. The callback signature already
demanded a `Task<int>`, but after a successful session the value was
unreachable, so an embedded host whose `Main` must return it had to capture
it in a closure.
* Do not dispose the server's `CancellationTokenSource` when the callback was
abandoned while still running: it holds the token, and `token.WaitHandle` or
`CreateLinkedTokenSource` would then throw `ObjectDisposedException` inside
the caller's own code.
* Skip the graceful wait entirely on a failed launch. Nothing is connected, so
there is no transport closure for the callback to observe; only the fixed
cancellation grace applies and an unwinding caller no longer pays
`ServerShutdownTimeout`.
* Clamp bounded waits instead of trusting the caller's `TimeSpan`. A negative
value (or `Timeout.InfiniteTimeSpan`) made `Task.Delay` throw from a path
documented never to throw; an oversized one is capped to the largest delay
.NET Framework accepts.
* Move `TcpListener.Start()` inside the cleanup `try`: it creates the socket
before binding, so a bind failure leaked it because the caller never received
a listener it could stop.
* Log a late failure from an abandoned callback rather than only observing it.
* Cache the in-process host's `ProcessId` instead of allocating a `Process` per
property read.
* Document that `Dispose()` blocks, that cancellation is bounded rather than
immediate, and why the two launch paths still build different argument
shapes; fix the shadowed `cancellationToken` in the PACKAGE.md sample.
Tests: cover `ShutdownAsync`, `ServerExitCode`, and fail-fast on a server that
stops without connecting; make `Dispose_IsIdempotent` able to fail by asserting
on observed transport closes rather than callback invocations; replace the
hand-rolled throws helper with `Assert.ThrowsExactlyAsync`; tighten the
shutdown-bound assertion; and stop leaking `Process` handles.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A third review round found three ways the teardown contract did not hold up
under concurrency.
`Dispose()` and `ShutdownAsync()` each had their own `Interlocked.Exchange`
guard, so whichever call lost the race returned while teardown was still
running -- reporting to its caller that the application had stopped when it had
not. Replace both guards with one lazily started, shared teardown task: every
entry point now joins the same task, so `Dispose` after (or racing)
`ShutdownAsync` returns only once the application has actually stopped, and the
whole thing stays idempotent. `MtpServerClient` no longer keeps its own guard
either; it delegates to the host, whose teardown is the single source of truth.
`ShutdownAsync()` could still block the very thread it exists to protect: it
closed the transport before its first await, and closing the connection waits
up to five seconds for the read loop. The shared task is started with
`Task.Run`, so the whole teardown -- transport close included -- runs on the
thread pool and `ShutdownAsync` returns immediately. `MtpServerProcess`
likewise now runs its (bounded but synchronous) kill off the calling thread
rather than pretending to be async while blocking.
`CancellationTokenSource.Cancel()` executes registrations synchronously on the
calling thread, so a caller registration that blocked would prevent the
five-second cancellation grace from ever starting and make the "bounded"
shutdown unbounded. Start the cancellation separately and begin the grace
regardless. When a registration is still executing once the callback has
finished, the source is reported as unsafe to dispose for the same reason an
abandoned callback is: leaking one `CancellationTokenSource` beats a
use-after-dispose inside the caller's code.
Adds a test that a `Dispose` racing an in-flight `ShutdownAsync` blocks until
the shared teardown completes, and that both share one teardown.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… fix ExitCode
A fourth review round found that only the in-process host had been converted to
the shared-teardown design. `MtpServerProcess` kept its old early-return guard,
so on the external-process path a `Dispose()` that followed or raced
`ShutdownAsync()` returned while `SafeKill`'s bounded `WaitForExit` was still
running on another thread -- losing exactly the guarantee that wait exists for
(a caller may delete the application directory immediately after disposal), and
making `await ShutdownAsync()` report that the server had stopped when it had
not. That contradicted the comment the previous commit added to
`MtpServerClient.Dispose`, which claimed both paths joined an in-flight
teardown. Convert `MtpServerProcess` to the same lazily created shared task, so
the contract is now uniform across both implementations of `IMtpServerHost`,
and reconcile the interface docs, which previously described the opposite rule.
Adding real coverage for `ShutdownAsync` on the external-process path then
surfaced a genuine bug: `MtpServerProcess.ExitCode` was always `null` after
teardown, because a `Process` cannot be queried once disposed. Capture the exit
code during teardown instead -- before the kill, so an application that already
exited on its own reports its real code rather than the kill's, and before
`Process.Dispose()`, after which nothing is readable.
Also fixes a race in the in-process test fixture that made the suite flaky on
net462: the callback published its `FakeMtpServer` only after its own connect
call returned, but the client's accept can complete first, so a test could
reach `Value` before the callback had set it. The fixture now exposes a
`Connected` signal the launch helper awaits. 6/6 clean net462 runs and 5/5
clean net8.0 runs afterwards.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI balanced review requested due to automatic review settings August 31, 2026 19:22
Comment on lines +182 to +193
if (serverTask is not null)
{
// 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.
if (!await ShutdownServerAsync(serverTask, serverCancellation!, TimeSpan.Zero, logger).ConfigureAwait(false))
{
// 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;
}
}
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Expert test review — PR #10898

GradeTestMutationNotesHow to improve
B (80–89)new MtpServerClientInProcessTests.
Dispose_CallbackFaultsDuringShutdown_
DoesNotThrow
2/3 killedReads server.Completion.Exception but never asserts on it, so a mutation changing the faulted exception's identity/message survives.Assert the captured exception's message/type instead of discarding it after touching .Exception.
B (80–89)new MtpServerClientInProcessTests.
LaunchInProcessAsync_HonorsTheStatefulOption
3/4 killedOnly asserts the sent initialize args; a mutation dropping the negotiated round-trip value on client.Capabilities is not caught.Also assert client.Capabilities.IsStateful reflects the negotiated round-trip value, not only the sent request.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_PassesCompleteServerModeArguments
5/5 killedVerifies exact ordered arguments, dynamic port and fixed count; strong regression guard for the generated argument array.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_DrivesInitializeDiscoverRunAndExit
6/6 killedEnd-to-end drive through initialize/discover/run/exit with distinct assertions per state transition.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackFaultsBeforeConnecting_
PreservesCallbackException
2/2 killedAsserts the exact exception instance is preserved as inner exception, not just its type.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackFaultsAsynchronouslyBeforeConnecting_
PreservesCallbackException
2/2 killedCovers the async-throw variant with the same identity assertion as the sync case.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackExitsWithoutConnecting_
FailsFastInsteadOfWaitingOutTheTimeout
3/3 killedChecks both the reported exit code in the message and a timing bound guarding against a slow-timeout regression.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackExitsBeforeConnecting_
ReportsExitCode
2/2 killedAsserts the specific exit code surfaces in the exception message.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CallbackReturnsNullTask_Fails
2/2 killedAsserts the specific inner exception type for a misbehaving callback returning a null task.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_NullCallback_Throws
1/1 killedFocused single-assertion guard-clause test using the exact exception type.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_AlreadyCanceled_
DoesNotInvokeCallback
2/2 killedVerifies both the cancellation exception and, via an interlocked counter, that the callback never ran.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_CanceledWhileConnecting_
CancelsTheCallbackToken
2/2 killedConfirms both the caller-side cancellation and that the callback's own token observed cancellation.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_ConnectionTimeoutElapses_
FailsWithTimeoutMessage
3/3 killedDistinguishes the connection timeout from the deliberately huge shutdown timeout via message and elapsed-time bound.
A (90–100)new MtpServerClientInProcessTests.
Dispose_ClosesTransportAndAwaitsTheCallback
4/4 killedChecks pre/post completion state, the callback's returned exit code, and the client's reported exit code.
A (90–100)new MtpServerClientInProcessTests.
ShutdownAsync_ClosesTransportAndAwaitsTheCallback_
WithoutBlocking
4/4 killedVerifies await-completion, exit code, a fast follow-up Dispose, and single-teardown count together.
A (90–100)new MtpServerClientInProcessTests.
Dispose_FromANotificationHandler_
DoesNotSelfWaitOnTheReadLoop
1/1 killedTargeted timing assertion guards against a specific re-entrancy deadlock regression.
A (90–100)new MtpServerClientInProcessTests.
Dispose_WhileShutdownAsyncIsInFlight_
WaitsForTheSameTeardown
3/3 killedUses a controlled release gate to prove the racing Dispose genuinely blocks, then confirms a single shared teardown.
A (90–100)new MtpServerClientInProcessTests.
Dispose_IsIdempotent
3/3 killedConfirms exactly one transport close and one completion despite three Dispose calls, plus a fast-return bound.
A (90–100)new MtpServerClientInProcessTests.
Dispose_CallbackIgnoresShutdown_
ReturnsWithinTheDocumentedBound
2/2 killedVerifies both the documented abandonment time bound and the logged "abandoning" diagnostic.
A (90–100)new MtpServerClientInProcessTests.
RunTestsAsync_Canceled_
SendsCancelRequestToTheHostedApplication
2/2 killedConfirms both the client-side cancellation exception and the wire-level cancel notification reaching the fake server.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_MultipleRequestsOnOneSession_
ReuseTheSameConnection
3/3 killedChecks keep-alive negotiation, single-connection reuse count, and total request count together.
A (90–100)new MtpServerClientInProcessTests.
LaunchInProcessAsync_IgnoresEnvironmentVariablesAndWarns
1/1 killedAsserts the specific warning content naming the ignored option.
A (90–100)new MtpServerClientInProcessAcceptanceTests.
InProcessHost_
DiscoversAndRunsItsOwnMSTestNodes_
WithoutStartingAProcess
5/5 killedReal end-to-end embedded-host run asserting build success, exit code, and each marker line the generated app emits.
A (90–100)mod MtpServerClientAcceptanceTests.
DiscoverAndRun_ViaSourcePackageClient_
ReportsExpectedTestNode
1/1 killedNew lines assert the external-process ShutdownAsync/Dispose share a teardown and report an exit code.
A (90–100)mod MtpServerClientSourcePackageConsumerTests.
HostileConsumer_
CompilesAgainstPackedSource
N/ACompile-only guard; added lines correctly extend surface coverage to ShutdownAsync and the in-process launch path.

This advisory comment was generated automatically. Grades are heuristic
and informational — they do not block merging. Suggestions on the Files
changed tab can be applied with one click. Re-run with
/review-tests.

🤖 Automated content by GitHub Copilot. Generated by the Test Reviewer on PR (on open / sync) workflow. · auto · 159.6 AIC · ⌖ 2.87 AIC · ⊞ 16.9K · [◷]( · )

@github-actions

Copy link
Copy Markdown
Contributor

🧵 Parallel-safety audit — PR #10898

Parallelization — audited assemblies:

Test assemblyScopeWorkersAnalyzer coverage
MSTest.Acceptance.IntegrationTestsMethodLevel (default [assembly: Parallelize(Scope = ExecutionScope.MethodLevel, Workers = 0)] in Program.cs)CPU countcoverable once the parallel-safety analyzers ship (attribute-based opt-in)
Microsoft.Testing.Platform.Acceptance.IntegrationTestsMethodLevel (Program.cs)CPU countcoverable once shipped
Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTestsMethodLevel (Program.cs)CPU countcoverable once shipped

This PR did not touch any .runsettings/testconfig.json/Directory.Build.* parallelization config — no assembly's scope changed.

Findings: A (global-state) 0 · B (paths) 0 · C (declaration) 0 · D (over-serialization) 0 — by severity: Critical 0 · High 0 · Warning 0 · Info 0.

No unsafe call sites found in the lines this PR added or modified.

  • MtpServerClientAcceptanceTests.cs — added lines only call client.ShutdownAsync() / assert ServerExitCode; no process-global state touched.
  • MtpServerClientSourcePackageConsumerTests.cs — added lines call into an isolated generated test asset (DriveAsync/DriveInProcessAsync); no shared/relative paths, env vars, culture, or console state mutated at the assembly level (Console.WriteLine here writes to the generated app's own redirected output, not the auditing process).
  • MtpServerClientInProcessAcceptanceTests.cs (new) — uses TestAsset.GenerateAssetAsync, which allocates a unique GUID-suffixed TempDirectory per instance (TestAsset.cs:19-26, throws if a path already exists) — no shared-path collision. CreateChildEnvironment() builds a fresh Dictionary passed only to the spawned child process's environment, not Environment.SetEnvironmentVariable, so it does not mutate this process's state.
  • FakeMtpServer.cs / MtpServerClientInProcessTests.cs (new) — each test constructs its own TcpListener/FakeMtpServer bound to an OS-assigned loopback port (new TcpListener(IPAddress.Loopback, 0)), so there is no fixed-port collision between concurrently-running tests; no static/process-global state is written.

Nothing here implies a testability or smell concern beyond this audit's scope.

Advisory only — heuristic, non-blocking. Re-run with /parallel-audit. This audit answers "is it parallel-safe?"; for testability, smells, or flakiness see the detect-static-dependencies / test-smell-detection / test-anti-patterns analyses.

🤖 Automated content by GitHub Copilot. Generated by the Parallel-safety audit on PR (on open / sync) workflow. · auto · 117.1 AIC · ⌖ 3.53 AIC · ⊞ 24.8K · [◷]( · )

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated content by GitHub Copilot. Generated by the Test Reviewer on PR (on open / sync) workflow. · auto · 159.6 AIC · ⌖ 2.87 AIC · ⊞ 16.9K ·

client.Dispose();

Assert.IsTrue(server.Completion.IsFaulted);
_ = server.Completion.Exception;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test review · Grade B (80–89) — Reads server.Completion.Exception but never asserts on it, so a mutation changing the faulted exception's identity/message would survive.

Assert the captured exception's message/type instead of discarding it after touching .Exception.

Suggested change
_=server.Completion.Exception;
Exception?faultException=server.Completion.Exception?.GetBaseException();
Assert.IsInstanceOfType<InvalidOperationException>(faultException);
Assert.AreEqual("The application failed while shutting down.",faultException!.Message);

? typed
: SerializerUtilities.Deserialize<InitializeRequestArgs>((IDictionary<string, object?>)initialize.Params!);

Assert.AreEqual(isStateful, args.Capabilities.IsStateful, "The in-process path must forward the client's stateful capability unchanged.");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test review · Grade B (80–89) — Only asserts the sent initialize args; a mutation dropping the negotiated round-trip value on client.Capabilities is not caught.

Also assert client.Capabilities.IsStateful reflects the negotiated round-trip value, not only the sent request.

Suggested change
Assert.AreEqual(isStateful,args.Capabilities.IsStateful,"The in-process path must forward the client's stateful capability unchanged.");
Assert.AreEqual(isStateful,args.Capabilities.IsStateful,"The in-process path must forward the client's stateful capability unchanged.");
Assert.AreEqual(isStateful,capabilities.IsStateful,"The negotiated capability the client stores must reflect the server's round trip, not just the sent request.");

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

🤖 Automated review by GitHub Copilot. Generated by the Expert Code Review workflow. To request a follow-up action, reply by tagging @copilot directly.

✅ 22/22 dimensions clean — no findings.

Summary: This PR adds MtpServerClient.LaunchInProcessAsync for embedded hosts (MAUI, Android/iOS) that cannot Process.Start. The implementation is thorough and well-structured:

  • Threading & Concurrency: Shutdown is idempotent via lock + lazy Task, Volatile.Read/Write for cross-thread exit code, Task.Run avoids sync-context deadlocks. No shared mutable state without synchronization.
  • Security & IPC: Loopback TCP only, no command-line injection surface (arguments are array-based), WASM fails fast with PlatformNotSupportedException.
  • Public API: All new API is internal (source-only package). No init accessors. No PublicAPI.Unshipped.txt changes needed.
  • Performance: GetCurrentProcessId() snapshotted once in constructor. No hot-path allocations. WaitBoundedAsync clamps timeouts correctly.
  • Cross-TFM: #if NET8_0_OR_GREATER guard on AcceptTcpClientAsync(CancellationToken). RuntimeInformation used for OS detection on net462.
  • Resource Management: Every disposable (TcpListener, TcpClient, CancellationTokenSource, Process) has cleanup in both success and error paths. Pending accepts are neutralized. ServerCancellation is only disposed when safe.
  • Defensive Coding: Callback exceptions are wrapped as InnerException in MtpServerConnectionClosedException. Null task from callback is caught. Cancel() runs on thread pool to avoid blocking registration.
  • Error Handling: Shared teardown task wrapped in catch-all so it never faults. ObserveFailure prevents UnobservedTaskException. Every teardown helper is non-throwing.
  • Tests: 68 unit tests + acceptance tests covering argument shape, lifecycle, faults, cancellation, timeouts, idempotent disposal, notification-handler disposal, and the real end-to-end in-process path.
  • Documentation: Changelog, protocol intro, PACKAGE.md all updated. XML doc comments are thorough.

The refactoring of shared transport logic into MtpServerConnector and the IMtpServerHost abstraction is clean and prevents the two launch paths from drifting. The existing LaunchAsync(path) path is provably unchanged (same argument string, same failure messages, same teardown order).

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

Review tier: Balanced
Findings: 3 Medium severity

New issues introduced by this change (3)
SeverityFinding
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.csMtpJsonRpcConnection.Dispose() closes the socket and then waits up to its 5-second read-loop…
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.csShutdownAsync writes this nullable property from the teardown worker while callers can read…
Medium severitysrc/​Platform/​Microsoft.Testing.Platform.ServerMode.Client.Sources/​Client/​MtpServerInProcessHost.cs — A canceled callback takes this branch without an inner exception, although the package…
What changed in this PR

Adds in-process launch support to the source-only MTP server-mode client for embedded/mobile hosts, addressing #10890 and enabling scenarios related to #9809.

Changes:

  • Adds callback-based in-process hosting with shared transport setup.
  • Adds bounded asynchronous shutdown and server exit-code reporting.
  • Adds documentation, unit coverage, and real-MSTest acceptance coverage.
FileDescription
MtpServerClientInProcessTests.csTests in-process lifecycle and protocol behavior.
FakeMtpServer.csSupports server-to-client dial-back mode.
MtpServerClientInProcessAcceptanceTests.csExercises a real in-process MSTest application.
MtpServerClientAcceptanceTests.csCovers external-process shutdown and exit code.
MtpServerClientSourcePackageConsumerTests.csExtends source-package compile coverage.
PACKAGE.mdDocuments embedded-host usage and lifecycle.
MtpServerProcess.csAdopts shared host and shutdown abstractions.
MtpServerInProcessHost.csImplements callback hosting and teardown.
MtpServerConnector.csCentralizes listener and transport setup.
MtpServerClientOptions.csAdds the shutdown timeout option.
MtpServerClientExceptions.csSupports preserved inner exceptions.
MtpServerClient.csExposes in-process launch and shutdown.
IMtpServerHost.csDefines common host ownership behavior.
IMtpServerClient.csAdds shutdown and exit-code members.
001-protocol-intro.mdDocuments reference-client launch modes.
Changelog-Platform.mdRecords the new embedded-host capability.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +254 to +258
Connection.Dispose();
SafeDispose(_client, _logger, "Disposing the accepted client socket");
MtpServerConnector.SafeStop(_listener, _logger);

bool stopped = await ShutdownServerAsync(_serverTask, _serverCancellation, _shutdownTimeout, _logger).ConfigureAwait(false);
/// Gets the exit code the hosted application returned, or <see langword="null"/> while it is still
/// running (or when it failed or was abandoned rather than returning one).
/// </summary>
public int? ExitCode { get; private set; }
Comment on lines +311 to +315
if (serverTask.IsCanceled)
{
return new MtpServerConnectionClosedException(
"The in-process Microsoft.Testing.Platform application was canceled before connecting back.");
}
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add in-process launch support to the MTP server-mode client

2 participants

@Evangelink