# RFC 017 - Custom test host launcher

- [ ] Approved in principle
- [x] Under discussion
- [x] Implementation
- [ ] Shipped

## Summary

Introduce **`ITestHostLauncher`**: a public, experimental Microsoft.Testing.Platform (MTP)
extension point that lets an extension control **how** the out-of-process test host is launched,
instead of the platform always doing `Process.Start`. The platform still owns everything around
the launch — argument/environment preparation, the controller↔host IPC pipe, PID tracking,
`ITestHostProcessLifetimeHandler` callbacks, and exit-code reconciliation — and simply delegates
the single "create and start the test host" step to the registered launcher.

The hook is deliberately **agnostic of the launch mechanism**: the launcher does not have to start a
local OS process. It can deploy and activate a packaged application, launch a container, or start
the host on a remote machine. To make this explicit, the launcher returns an `ITestHostHandle` that
exposes only the lifecycle the platform needs (`WaitForExitAsync`, `ExitCode`, `HasExited`,
`Terminate`) plus an optional free-form `Identifier` string used purely for diagnostics.

The motivating scenario is **packaging and deployment of MSIX-packaged applications — both UWP and
WinUI** (see [#2784](https://github.com/microsoft/testfx/issues/2784)): packaged/MSIX apps cannot be
started with `Process.Start` and must be deployed and then activated by AUMID. UWP and packaged WinUI
share this exact mechanism, which is why VSTest exposes a single `UwpTestHostRuntimeProvider` for
both; unpackaged apps similarly benefit from a custom deploy + launch step. The same hook also
enables launching the test host under a debugger, elevated, inside a container, or on a remote
machine.

## Motivation

MTP runs the test host out-of-process whenever a "test host controller" extension is active (hang
dump, crash dump, or any `ITestHostProcessLifetimeHandler` / `ITestHostEnvironmentVariableProvider`).
That work happens in `TestHostControllersTestHost`, which prepares a `ProcessStartInfo` (arguments,
environment variables including the `MONITORTOHOST` pipe name) and then launches the host with a
single call:

```csharp
using IProcess testHostProcess = process.Start(processStartInfo);
```

Everything downstream only needs a handful of things from the returned handle — a way to observe
exit (`WaitForExitAsync()`, `ExitCode`, `HasExited`), optionally an identifier for logging, and a
way to tear it down — plus the child connecting back on the named pipe whose name was
injected via an environment variable. **`Process.Start` is the only assumption that does not hold
universally.** Several real scenarios need a different launch mechanism:

- **Packaged WinUI/MSIX**: a packaged app must be deployed (in Developer Mode, register the loose
  layout) and then activated by Application User Model ID (AUMID) via `IApplicationActivationManager`,
  not started from an executable path. This is the blocker behind
  [#2784](https://github.com/microsoft/testfx/issues/2784) and the reason VSTest's
  `UwpTestHostRuntimeProvider` exists.
- **Debugger attach/launch**: start the host suspended (or under a debugger launcher such as
  `vsdbg` / `WinDbg` / `dlv`) and only then resume.
- **Elevation**: run the test host as administrator (UAC) or as another user.
- **Container / remote**: launch the host inside a container (`docker run`) or on a remote device
  over SSH/WinRM, then bridge the pipe — neither of which exposes a local, queryable PID.

Today none of these is possible without forking the platform. The existing experimental
`ITestHostExecutionOrchestrator` sits at the wrong layer (see [Alternatives](#alternatives-considered)). This
RFC adds the *minimal* hook at exactly the launch site.

## Goals

- Let an extension substitute the test host launch step while the platform keeps owning
  argument/env preparation, IPC, lifetime-handler dispatch, and exit-code handling.
- Keep hang dump, crash dump, and all `ITestHostProcessLifetimeHandler` /
  `ITestHostEnvironmentVariableProvider` extensions working unchanged when a custom launcher is
  present.
- Be generic enough to cover WinUI deploy+activate, debugger, elevation, container, and remote
  launch with one shape, **without assuming the launched thing is a local OS process**.
- Follow MTP's experimental-API conventions so the surface can evolve before stabilizing.

## Non-goals

- Replacing the *entire* run loop (that is `ITestHostExecutionOrchestrator`'s job).
- Remote **device deployment/bootstrapping** of the Windows App SDK framework + agent (VSTest's
  `Microsoft.UniversalApps.Deployment` has no public redistributable; out of scope — local launch
  only).
- Shipping a *complete* packaged UWP/WinUI (MSIX) deployment story. This RFC adds the platform hook;
  a reference consumer (`Microsoft.Testing.Extensions.PackagedApp`) implements the deploy-and-launch path,
  while packaged AUMID activation remains a separate follow-up.
- Changing the in-process (single-process, `ConsoleTestHost`) execution path.

## Detailed design

### Where it plugs in

The hook lives in the **test host controllers** layer, next to the existing lifetime-handler and
environment-variable-provider extension points, and is registered through
`ITestHostControllersManager`.

```csharp
namespace Microsoft.Testing.Platform.Extensions.TestHostControllers;

/// <summary>
/// Allows an extension to control how the out-of-process test host is launched,
/// replacing the platform's default <c>Process.Start</c> behavior.
/// </summary>
[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]
public interface ITestHostLauncher : ITestHostControllersExtension // : IExtension
{
    /// <summary>
    /// Creates and starts the test host. The platform has already prepared the file name,
    /// arguments, and environment variables (including the controller IPC pipe name) carried by
    /// <paramref name="context"/>. The implementation must return a handle the platform can monitor.
    /// </summary>
    Task<ITestHostHandle> LaunchTestHostAsync(
        TestHostLaunchContext context,
        CancellationToken cancellationToken);
}
```

The platform passes the fully-prepared launch information:

```csharp
[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]
public sealed class TestHostLaunchContext
{
    public TestHostLaunchContext(
        string fileName,
        IReadOnlyList<string> arguments,
        IReadOnlyDictionary<string, string?> environmentVariables,
        string? workingDirectory);

    /// <summary>The default test host executable path the platform would have started.</summary>
    public string FileName { get; }

    /// <summary>Arguments, already including the test host controller PID option.</summary>
    public IReadOnlyList<string> Arguments { get; }

    /// <summary>
    /// The final environment for the test host, after all
    /// <see cref="ITestHostEnvironmentVariableProvider"/> ran. Includes the
    /// controller↔host IPC pipe name the host must connect back on.
    /// </summary>
    public IReadOnlyDictionary<string, string?> EnvironmentVariables { get; }

    /// <summary>The working directory, or null to inherit the current one.</summary>
    public string? WorkingDirectory { get; }
}
```

And the launcher returns a launch-mechanism-agnostic handle (the platform adapts it to its internal
monitoring contract):

```csharp
[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]
public interface ITestHostHandle : IDisposable
{
    /// <summary>
    /// Free-form diagnostic identifier (a PID, container id, remote host:pid, …) or null. The
    /// platform never relies on it for control flow.
    /// </summary>
    string? Identifier { get; }

    /// <summary>Only valid once <see cref="HasExited"/> is true (reading it earlier is undefined).</summary>
    int ExitCode { get; }
    bool HasExited { get; }

    /// <summary>Waits for exit, or for the token to be canceled. May be awaited more than once.</summary>
    Task WaitForExitAsync(CancellationToken cancellationToken);

    /// <summary>Best-effort teardown (e.g. when hang dump aborts the run).</summary>
    void Terminate();
}
```

`ITestHostHandle` extends `IDisposable`: the platform owns the handle for the whole lifetime of the
test host and disposes it once the host has exited, so implementations release any OS resources they
hold (process objects, sockets, container clients, …) in `Dispose`. `WaitForExitAsync` takes a
`CancellationToken` so the platform can stop waiting on cancellation; the controller host still
reconciles the real OS exit code afterwards (on cancellation it terminates the host and waits for it
to fully exit).

Registration mirrors the existing methods on `ITestHostControllersManager`:

```csharp
public interface ITestHostControllersManager
{
    // existing: AddEnvironmentVariableProvider(...), AddProcessLifetimeHandler(...)

    [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]
    void AddTestHostLauncher(Func<IServiceProvider, ITestHostLauncher> testHostLauncherFactory);

    [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]
    void AddTestHostLauncher<T>(CompositeExtensionFactory<T> compositeServiceFactory)
        where T : class, ITestHostLauncher;
}
```

### Platform integration (what changes inside MTP)

1. **Swap the launch call.** In `TestHostControllersTestHost.InternalRunAsync`, at the current
   `process.Start(processStartInfo)` site (after `BeforeTestHostProcessStartAsync` and after all
   env-var providers ran), if a launcher is registered, build a `TestHostLaunchContext` from the
   `ProcessStartInfo` and `await launcher.LaunchTestHostAsync(...)`. Otherwise keep the default
   `process.Start`. The returned `ITestHostHandle` is adapted to the internal `IProcess` monitoring
   contract — which only uses `WaitForExitAsync` / `ExitCode` / `HasExited` / `Kill` (and an
   internal `Exited` event synthesized from the exit task for an informational log). The
   premature-exit check is gated on `HasExited` only (not on whether an identifier is available), so
   a launcher that returns no identifier (container/remote/AUMID) is monitored purely through the
   handle lifecycle and the IPC PID handshake.
2. **Force the controller host.** A launcher makes `RequireProcessRestart` `true` when one is
   registered (computed in `TestHostControllersManager.BuildAsync`, checked in
   `TestHostBuilder.Modes.cs`); without this, a run with *only* a launcher (no dump/lifetime
   extension) would stay in-process and there would be nothing to launch.
3. **Singleton.** At most one launcher may be registered; a duplicate fails fast at build time with
   a localized "only one test host launcher" error.
4. **Preserve ordering and services.** Because the call stays at the same point,
   `ITestHostEnvironmentVariableProvider`, the `MONITORTOHOST` IPC pipe, the PID handshake, and
   `ITestHostProcessLifetimeHandler` (and therefore hang dump and crash dump) all keep working with
   no changes.

### Authorizing an AppContainer on the controller pipe

The platform creates the `MONITORTOHOST` pipe with the equivalent of `PipeOptions.CurrentUserOnly`: the
pipe is owned by the creating token's *owner* SID and its DACL contains a single ACE granting that same
SID full control. That is enough for every ordinary child process, and it is what gives the pipe its
current-user and elevation protection.

It is *not* enough for a Windows AppContainer (a true UWP host, or a WinUI host configured for
AppContainer). An AppContainer runs with a **restricted token**, and Windows grants access only when both
the normal access check *and* the restricted-SID check succeed. The restricting SIDs of an AppContainer
contain the package SID, so a DACL that names only the user SID denies the host even though it belongs to
the same signed-in user — knowing the pipe name changes nothing.

The pipe must exist before the host is launched, so a launcher cannot contribute the package identity from
`LaunchTestHostAsync`. A launcher that needs it therefore *also* implements a hook that is deliberately
phrased in OS-neutral terms — core MTP does not name AppContainer, MSIX or packaging anywhere in this
surface, so the same hook can serve any launcher that starts the host under a restricted identity of its
own:

```csharp
namespace Microsoft.Testing.Platform.Extensions.TestHostControllers;

[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]
public interface ITestHostControllerConnectionAuthorizer
{
    /// <summary>
    /// Returns the operating-system security identities that must be able to reach the
    /// controller-to-host connection in addition to the current user.
    /// </summary>
    Task<IReadOnlyList<string>> GetAuthorizedSecurityIdentitiesAsync(
        string testHostFileName,
        CancellationToken cancellationToken);
}
```

The AppContainer specifics live on the two sides that legitimately own them: the packaged-app extension,
which knows it launches an AppContainer and derives its package SID, and the platform's internal Windows
named-pipe security helper, which knows how the OS expresses that identity. What core's *contract* says is
only "here is a restricted identity that must be able to reach the connection".

`TestHostControllersTestHost` calls it immediately before creating the pipe. The resulting security
descriptor is deliberately minimal:

| Principal | Rights |
| --- | --- |
| The creating token's owner SID (owner and group of the pipe) | `PipeAccessRights.FullControl` (`0x1f019f`) — identical to what `PipeOptions.CurrentUserOnly` grants |
| Each authorized AppContainer package SID | `ReadWrite \| Synchronize` (`0x12019b`) only |

and the DACL is protected (`D:P`) so nothing can be inherited into it. The package mask deliberately
excludes `FILE_CREATE_PIPE_INSTANCE`, `DELETE`, `WRITE_DAC` and `WRITE_OWNER`, so an authorized package can
talk to the controller but can never create a second instance of the pipe and impersonate it. The pipe is
additionally created with `PIPE_REJECT_REMOTE_CLIENTS`.

Authorization-enabled Windows pipes are also qualified with the packaged-app-required `LOCAL\` namespace:
the controller creates `\\.\pipe\LOCAL\<name>` and hands `LOCAL\<name>` to the host. Ordinary runs retain
their existing pipe name unchanged.

No mandatory integrity label is emitted. The pipe keeps the controller's own integrity level, so Mandatory
Integrity Control stays a second gate behind the DACL; a lowbox (AppContainer) token's access check is
satisfied by the package-SID ACE and is not blocked by that label.

Security rules the platform enforces on its own, independently of the extension:

- Only a **specific** AppContainer SID may be authorized. Users, groups, `Everyone`, and in particular the
  catch-all `ALL APPLICATION PACKAGES` (`S-1-15-2-1`) and `ALL RESTRICTED APPLICATION PACKAGES`
  (`S-1-15-2-2`) SIDs are rejected, and the run fails with an actionable error rather than silently
  falling back to a weaker or a wider pipe.
- Returning an empty collection — which is what every non-AppContainer launcher does — leaves the pipe
  byte-for-byte as it is today.
- The whole path is Windows-only. On any other operating system the returned values are logged and
  ignored, and the existing pipe implementation is used unchanged.

Loopback exemptions (`CheckNetIsolation LoopbackExempt`) are explicitly *not* part of the design: they
apply to network loopback sockets, not to named-pipe DACLs, and would not help here.

### Contract requirements on the launcher
- The launched host **must** end up with the values in `context.EnvironmentVariables` (so it connects
  back on the controller pipe) and **must** receive `context.Arguments`. *How* those values reach the
  host is left to the launcher — they can be inherited from the environment, passed as activation
  arguments, or bridged through a broker. AUMID activation in particular cannot set per-launch
  environment variables, so a packaged-app launcher must transfer them another way.
- The returned handle must report exit reliably (`WaitForExitAsync`, `ExitCode`, `HasExited`) and
  support `Terminate()` (hang dump terminates the host through it). `WaitForExitAsync` may be awaited
  more than once, and must honor its `CancellationToken`. `ExitCode` is only required to be valid
  once `HasExited` is `true` (or after `WaitForExitAsync` completes); reading it on a still-running
  handle is undefined and implementations are not required to throw.
- The handle is `IDisposable`; the platform disposes it once the host has exited, so the launcher
  should release any OS resources it holds (process object, sockets, container client, …) in
  `Dispose`.
- `Identifier` is an optional free-form diagnostic string (PID, container id, remote `host:pid`, …)
  and may be `null`. The platform never relies on it for control flow.
- If the launcher cannot start the host it should throw; the platform surfaces it as a
  platform-setup failure.

> The `Quote` and `PasteArguments` helpers used in the examples below are placeholders for whatever
> argument/shell quoting the target mechanism needs (e.g. `PasteArguments` from dotnet/runtime for
> Windows command lines, POSIX single-quoting for a shell). Implement them carefully to avoid
> argument-injection bugs; the reference `Microsoft.Testing.Extensions.PackagedApp` extension (see the
> implementation PR) shows a concrete approach.

## Examples

All examples assume the extension is registered on the builder, e.g. from a `…Extensions` helper:

```csharp
builder.TestHostControllers.AddTestHostLauncher(sp => new MyLauncher(sp));
```

### 1. Packaged WinUI / MSIX (the motivating case)

Deploy the loose layout (Developer Mode) and activate the packaged app by AUMID instead of starting
an exe. The activated app self-hosts MTP (as the `MSTestRunnerWinUI` sample already does for the
in-process case); the launcher is responsible for getting the controller pipe name and correlation
id to it so it can connect back, since AUMID activation does not flow per-launch environment
variables on its own.

```csharp
public Task<ITestHostHandle> LaunchTestHostAsync(
    TestHostLaunchContext context, CancellationToken cancellationToken)
{
    // 1. Parse the .appxrecipe / AppxManifest.xml next to context.FileName to get the AUMID
    //    and (in Developer Mode) register the loose layout:
    //    new PackageManager().RegisterPackageByUriAsync(manifestUri, options);
    string aumid = AppxManifest.ResolveAumid(context.FileName);

    // 2. Activate, passing the SAME args the platform prepared. AUMID activation takes a single
    //    string. Full-trust desktop apps need Windows argv quoting; AppContainer apps need a versioned
    //    opaque payload that Application.OnLaunched restores before CreateBuilderAsync.
    var aam = (IApplicationActivationManager)new ApplicationActivationManager();
    string activationArguments;
    string? activationPayloadPath = null;
    if (manifest.UsesLaunchActivationArguments)
    {
        PackagedAppActivationData activationData = PackagedAppActivationArguments.Create(
            context.Arguments,
            manifest.LocalStateDirectory);
        activationArguments = activationData.Arguments;
        activationPayloadPath = activationData.PayloadPath;
    }
    else
    {
        activationArguments = PasteArguments(context.Arguments);
    }

    aam.ActivateApplication(aumid, activationArguments, ACTIVATEOPTIONS.AO_NONE, out uint pid);

    // 3. Wrap the activated app. AUMID activation cannot set per-launch environment variables, so the
    //    launcher must bridge the values the host needs from context.EnvironmentVariables (the
    //    MONITORTOHOST pipe name, correlation id, etc.) another way — e.g. activation arguments or a
    //    broker process the activated app reads on startup. The handle surfaces the activated PID as
    //    its (diagnostic-only) Identifier.
    return Task.FromResult<ITestHostHandle>(new ActivatedAppHandle(pid, activationPayloadPath));
}
```

> Note: enabling the controller→host pipe across the AppContainer sandbox additionally requires the
> pipe DACL to grant the exact package SID access. Argument delivery and pipe authorization are
> deliberately separate concerns: the reference packaged-app extension restores the opaque
> `LaunchActivatedEventArgs.Arguments` through a reusable bootstrap, and contributes the package SID
> through `ITestHostControllerConnectionAuthorizer` (see
> [Authorizing an AppContainer on the controller pipe](#authorizing-an-appcontainer-on-the-controller-pipe)).
> `CheckNetIsolation LoopbackExempt` is for network loopback and does not authorize named pipes.

For launch activation, Windows documents a 2,048-character argument envelope on
`SecondaryTile.Arguments`, one of the sources surfaced as `LaunchActivatedEventArgs.Arguments`.
The reference extension keeps payloads within that proven envelope inline. Larger MTP argument arrays
spill to a one-shot package `LocalState` file as authenticated ciphertext, with the random per-launch
key carried only in the activation string. This avoids plaintext persistence of filters, runsettings,
or secrets while preserving exact argument order and values.

### 2. Launch under a debugger

```csharp
public async Task<ITestHostHandle> LaunchTestHostAsync(
    TestHostLaunchContext context, CancellationToken cancellationToken)
{
    var psi = new ProcessStartInfo(context.FileName) { UseShellExecute = false };
    foreach (string arg in context.Arguments) psi.ArgumentList.Add(arg);
    foreach (var kvp in context.EnvironmentVariables.Where(kv => kv.Value is not null))
        psi.Environment[kvp.Key] = kvp.Value; // skip unset (null) vars
    psi.Environment["DOTNET_DefaultDiagnosticPortSuspend"] = "1"; // start suspended

    Process p = Process.Start(psi)!;
    await DebuggerLauncher.AttachAsync(p.Id, cancellationToken); // e.g. vsdbg / WinDbg / dlv
    await DebuggerLauncher.ResumeAsync(p.Id, cancellationToken);
    return new ProcessHandleAdapter(p);
}
```

### 3. Elevated (run as administrator)

```csharp
public Task<ITestHostHandle> LaunchTestHostAsync(
    TestHostLaunchContext context, CancellationToken cancellationToken)
{
    var psi = new ProcessStartInfo(context.FileName)
    {
        UseShellExecute = true,   // required for the UAC "runas" verb
        Verb = "runas",
    };
    foreach (string arg in context.Arguments) psi.ArgumentList.Add(arg);
    // NOTE: UseShellExecute = true cannot pass per-process env vars; an elevated launcher
    // must forward context.EnvironmentVariables another way (e.g. a temp response file the
    // host reads, or a broker that sets them) so the host still finds the controller pipe.
    Process p = Process.Start(psi)!;
    return Task.FromResult<ITestHostHandle>(new ProcessHandleAdapter(p));
}
```

This example deliberately shows a sharp edge: elevation via the shell loses per-process environment
variables, so the launcher is responsible for re-delivering them. The platform contract only
requires that the host ends up with `context.EnvironmentVariables`.

### 4. Container

Run the test host inside a container and bridge the pipe. `docker run --rm` is used so the container
is removed when it stops. The returned handle tracks the `docker run` client process; note that
killing the client alone does not reliably stop the container, so a real implementation should make
`Terminate()` run `docker stop`/`docker rm` (or run with `--init` and rely on `--rm`) rather than
just killing the local client.

```csharp
public Task<ITestHostHandle> LaunchTestHostAsync(
    TestHostLaunchContext context, CancellationToken cancellationToken)
{
    var args = new List<string> { "run", "--rm", "--init" };
    foreach (var kvp in context.EnvironmentVariables.Where(kv => kv.Value is not null)) { args.Add("-e"); args.Add($"{kvp.Key}={kvp.Value}"); } // skip unset (null) vars
    // Map the controller pipe into the container (Windows named pipe / Unix domain socket mount).
    args.Add("--name");
    string containerName = $"mtp-{Guid.NewGuid():N}";
    args.Add(containerName);
    args.Add("test-image:latest");
    args.Add(context.FileName);
    args.AddRange(context.Arguments);

    var psi = new ProcessStartInfo("docker") { UseShellExecute = false };
    foreach (string a in args) psi.ArgumentList.Add(a);
    Process p = Process.Start(psi)!;
    // Wrap so Terminate() runs `docker stop <containerName>` (which tears down the container), not
    // just Kill() on the local docker client.
    return Task.FromResult<ITestHostHandle>(new DockerRunHandle(p, containerName));
}
```

### 5. Remote (SSH)

```csharp
public Task<ITestHostHandle> LaunchTestHostAsync(
    TestHostLaunchContext context, CancellationToken cancellationToken)
{
    string env = string.Join(' ', context.EnvironmentVariables
        .Where(kv => kv.Value is not null)
        .Select(kv => $"{kv.Key}={Quote(kv.Value!)}")); // values are nullable; skip unset vars
    string remoteCmd = $"{env} {Quote(context.FileName)} {string.Join(' ', context.Arguments.Select(Quote))}";

    var psi = new ProcessStartInfo("ssh") { UseShellExecute = false };
    psi.ArgumentList.Add("user@remote-host");
    psi.ArgumentList.Add(remoteCmd);
    Process ssh = Process.Start(psi)!; // tunnel the controller pipe over the SSH connection
    // The handle tracks the local ssh client; its Identifier could be the ssh client PID (diagnostic only).
    return Task.FromResult<ITestHostHandle>(new ProcessHandleAdapter(ssh));
}
```

## Alternatives considered

### Reuse `ITestHostExecutionOrchestrator`

MTP already ships an experimental `ITestHostExecutionOrchestrator`
(`ITestHostOrchestratorManager.AddTestHostOrchestrator`). It was rejected as the vehicle because it
sits **above** the controller: `OrchestrateTestHostExecutionAsync` runs in
`TestHostOrchestratorHost` and replaces the *entire* execution, returning only an exit code. An
implementation would have to re-create everything `TestHostControllersTestHost` provides —
environment-variable providers, the `MONITORTOHOST` IPC/PID handshake, and the
`ITestHostProcessLifetimeHandler` fan-out that **hang dump and crash dump depend on**. That is the
wrong granularity for "launch the host differently." The orchestrator remains the right tool for
whole-run concerns (e.g. retry/repeat that re-runs the host).

### Make the internal `IProcessHandler` replaceable via DI

`IProcessHandler` / `IProcess` are `internal` and surface `Process`-specific members (e.g.
`MainModule`). Exposing them publicly would leak implementation detail, over-commit the surface, and
bake in the "it's always a local process" assumption. A purpose-built, minimal, mechanism-agnostic
`ITestHostHandle` is cleaner and evolvable.

### A process-centric `ITestHostProcessLauncher` returning a `ProcessId`

An earlier draft of this RFC named the hook `ITestHostProcessLauncher` and returned an
`ITestHostProcessHandle` whose `ProcessId` was a mandatory `int`. That over-commits to "the test host
is a local OS process," which is false for container and remote launches and awkward for
AUMID-activated apps. The current design renames the types to drop "Process", replaces the `int`
process id with an optional free-form `string Identifier` (diagnostic only), drops the redundant
`Exited` event in favour of `WaitForExitAsync`, gives `WaitForExitAsync` a `CancellationToken` and
makes the handle `IDisposable` (so the platform can honor cancellation and deterministically release
handle resources), and names the teardown `Terminate()` instead of `Kill()`.

### Do nothing (keep `Process.Start`)

Leaves [#2784](https://github.com/microsoft/testfx/issues/2784) unsolvable on MTP for packaged apps
and blocks the debugger/elevation/container/remote scenarios, all of which today require forking the
platform.

## Compatibility and conventions

- **Experimental.** All new types and methods are gated behind
  `[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]`, consistent
  with the other test-host-controller-era experimental APIs.
- **Public API tracking.** New members are added to `PublicAPI.Unshipped.txt` with the `[TPEXP]`
  prefix.
- **No `init` accessors** on any new public API, per repo policy.
- **No behavior change when unused.** If no launcher is registered, the platform behaves exactly as
  today (`Process.Start`), and the controller host is selected only when it already would be.

## Open questions

- **CLI/debug integration.** Should the platform expose a built-in `--launcher`-style selector, or
  is builder/MSBuild registration sufficient for v1?
- **Cancellation semantics.** Define precisely what `Terminate()` must guarantee for remote/container
  launchers (best-effort teardown vs. synchronous termination).
- **Multiple launchers.** Singleton for v1; is there ever a composition story (e.g. debugger +
  elevation), or do implementers compose manually?
