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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,9 @@
shared source files as contentFiles/cs/any so a consumer (the dotnet/sdk 'dotnet test' implementation)
compiles them into its OWN assembly, keeping a single source of truth instead of hand-copying.

Internal, source-only package for the 'dotnet test' <-> Microsoft.Testing.Platform integration. It ships
shared source files as contentFiles/cs/any so a consumer (the dotnet/sdk 'dotnet test' implementation)
compiles them into its OWN assembly, keeping a single source of truth instead of hand-copying.

It hosts (a) the named-pipe wire contract (ObjectFieldIds.cs + Constants.cs) and (b) the terminal reporter
(reporter + rendering + state + the small platform abstractions it needs) together with its localized
It hosts (a) the whole named-pipe protocol source - the ObjectFieldIds.cs + Constants.cs wire contract plus the
message models + serializers for ids 0-12, the serializer registry and base infrastructure - and (b) the terminal
reporter (reporter + rendering + state + the small platform abstractions it needs) together with its localized
resources (TerminalResources.resx + xlf, wired up by the build-extension props this package ships - see
build/). The name is deliberately generic and carries 'Internal' so it is clearly NOT a public API / client
library (see also issue #5667, closed pending a real use case for a public MTP client).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,14 @@ break).
The package ships shared source as `contentFiles/cs/any` with `BuildAction=Compile`, so the consumer compiles it
into its **own** assembly and the `internal` types are visible without any `InternalsVisibleTo` plumbing:

- **Wire contract** (`contentFiles/cs/any/DotnetTestProtocol/`): `ObjectFieldIds.cs` (serializer/field ids) and
`Constants.cs` (handshake property names, execution modes, session-event types, test states, protocol version).
- **`dotnet test` named-pipe protocol** (`contentFiles/cs/any/DotnetTestProtocol/`): the whole serializer stack as a
single source of truth — `ObjectFieldIds.cs` (serializer/field ids) and `Constants.cs` (handshake property names,
execution modes, session-event types, test states, protocol version), the message **models** and **serializers**
for serializer ids 0–12 (including `AzureDevOpsLogMessage` = 11 and `DisplayMessage` = 12), the serializer registry
(`NamedPipeBase` + `RegisterSerializers`), the serializer base infrastructure (`BaseSerializer`,
`NamedPipeSerializer`) and the `INamedPipeSerializer`/`IRequest`/`IResponse` interfaces. The named-pipe **transport**
(`NamedPipeServer`/`NamedPipeClient`/framing) is intentionally **not** shared — it differs per repo — so a consumer
keeps its own transport and reuses the shared registry + serializers.
- **Terminal reporter** (`contentFiles/cs/any/TerminalReporter/`): the reporter + rendering + state types and the
small platform abstractions they need (`IConsole`/`IStopwatch`/`IColor`/`System*`, `RoslynString`,
`ApplicationStateGuard`, `StackTraceHelper`, `TargetFrameworkParser`, `TestRunSummaryHelper`).
Expand All @@ -30,18 +36,25 @@ into its **own** assembly and the `internal` types are visible without any `Inte

## Consuming it (plug-in requirements)

Reference the package and the source compiles into your assembly. For the terminal reporter source the consumer
must have:
Reference the package and the source compiles into your assembly. For the terminal reporter source and the
`dotnet test` protocol serializer stack the consumer must have:

- **`ImplicitUsings` enabled** — the build props supplies the extra global usings the shared source relies on
(`System.Text`, `System.Runtime.CompilerServices`, `System.Runtime.Versioning`, …) when implicit usings are on.
(`System.Text`, `System.Diagnostics`, `System.Diagnostics.CodeAnalysis`, `System.Globalization`,
`System.Runtime.CompilerServices`, `System.Runtime.Versioning`, …) when implicit usings are on.
- **A `LangVersion` that supports the `field` keyword** (preview/latest).
- **The `Microsoft.CodeAnalysis.EmbeddedAttribute` polyfill** — shipped by this package as source, so it is always
available (dotnet/sdk also defines its own for its copied IPC transport; the `internal sealed partial` polyfill
merges harmlessly).
- **The `Microsoft.CodeAnalysis.EmbeddedAttribute` polyfill** — the protocol registry/infra types and the terminal
reporter types are `[Microsoft.CodeAnalysis.Embedded]`, so the consumer needs this attribute. It is shipped once by
this package as source (the `internal sealed partial` polyfill merges harmlessly if the consumer also defines one).
- **XliffTasks** (only for localized satellites) — dotnet/sdk has it via Arcade.
- **Down-level polyfills (only for non-`NETCOREAPP` TFMs such as `net462`/`netstandard2.0`)** — the protocol source
uses positional records (need `System.Runtime.CompilerServices.IsExternalInit`) and nullable-reference annotations
(`System.Diagnostics.CodeAnalysis` nullable attributes). Modern net TFMs (e.g. dotnet/sdk) have these built in and
need nothing; a down-level consumer must supply the usual polyfills (as the in-repo standalone test project does by
compiling `src/Polyfills`).

The wire-contract source is zero-dependency and needs none of the above.
Only the `ObjectFieldIds`/`Constants` wire-contract subset is zero-dependency and needs none of the above; the rest of
the protocol serializer stack has the same requirements as the terminal reporter source.

> ℹ️ The terminal reporter ships a couple of small abstractions that also live in Microsoft.Testing.Platform. The
> internal ones (`IConsole`/`IStopwatch`/`System*`) are not exported by the platform, so they never conflict. The two
Expand All @@ -53,6 +66,9 @@ The wire-contract source is zero-dependency and needs none of the above.

## Scope

The message **models** and **serializers** are not shared yet because they still depend on `TestMetadataProperty`
(`Microsoft.Testing.Platform.Extensions.Messages`) and use a different class shape than the SDK's copy; sharing them
requires decoupling/unifying first.
The `dotnet test` protocol source is decoupled so it compiles standalone in a consumer that has no reference to
Microsoft.Testing.Platform: `BaseSerializer` carries its own `Unreachable()`/`DebugAssert` helpers (no
`ApplicationStateGuard`/`RoslynDebug`); the `DiscoveredTestMessage` trait uses the platform-decoupled `TraitMessage`
wire type instead of the public `TestMetadataProperty`; and `NamedPipeBase` is registry-only (no `PipeStream`/framing).
None of this changes the wire bytes — serializer ids, field ids, field order and layout are unchanged. Only the
named-pipe **transport** (`NamedPipeServer`/`NamedPipeClient`) is kept per-repo.
186 changes: 4 additions & 182 deletions src/Platform/Microsoft.Testing.Platform/IPC/NamedPipeBase.cs
Original file line number Diff line number Diff line change
@@ -1,28 +1,19 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

#if NET
using System.Buffers;
#endif

using System.IO.Pipes;

using Microsoft.CodeAnalysis;
#if NET
using Microsoft.Testing.Platform.Helpers;
#endif

namespace Microsoft.Testing.Platform.IPC;

// Serializer registry shared over the 'dotnet test' named pipe. This is the part of the pipe base that is shared as
// source with dotnet/sdk (via RegisterSerializers.RegisterAllSerializers); it is intentionally self-contained (no
// framing/transport, no PipeStream, no platform helpers) so it compiles standalone in a consumer that has no
// reference to Microsoft.Testing.Platform. The framing/transport lives in the repo-local NamedPipeConnectionBase.
[Embedded]
[SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", Justification = "Disposal is delegated to subclasses via DisposeBuffers().")]
internal abstract class NamedPipeBase
{
private readonly Dictionary<Type, INamedPipeSerializer> _typeSerializer = [];
private readonly Dictionary<int, INamedPipeSerializer> _idSerializer = [];
private readonly MemoryStream _serializationBuffer = new();
private readonly MemoryStream _messageBuffer = new();
private readonly byte[] _readBuffer = new byte[250000];

public void RegisterSerializer(INamedPipeSerializer namedPipeSerializer, Type type)
{
Expand All @@ -35,173 +26,4 @@ protected INamedPipeSerializer GetSerializer(int id)

protected INamedPipeSerializer GetSerializer(Type type)
=> _typeSerializer[type];

/// <summary>
/// Serializes <paramref name="message"/> using <paramref name="serializer"/>, frames it with a
/// 4-byte size header and 4-byte serializer-ID prefix, writes the frame to <paramref name="stream"/>,
/// flushes, and (on Windows) waits for the pipe to drain.
/// </summary>
#if !MTP_MSBUILD_TASKS
[UnsupportedOSPlatform("browser")]
#endif
protected async Task WriteMessageAsync(PipeStream stream, INamedPipeSerializer serializer, object message, CancellationToken cancellationToken)
{
// Serialize the message body
_serializationBuffer.Position = 0;
serializer.Serialize(message, _serializationBuffer);

// Build the framed message:
// 4 bytes – total payload length (serializer ID + body)
// 4 bytes – serializer ID
// N bytes – serialized body
_messageBuffer.Position = 0;
int sizeOfTheWholeMessage = (int)_serializationBuffer.Position + sizeof(int);

try
{
// Write the message size header
#if NET
byte[] bytes = ArrayPool<byte>.Shared.Rent(sizeof(int));
try
{
if (!BitConverter.TryWriteBytes(bytes, sizeOfTheWholeMessage))
{
// TryWriteBytes only fails if destination is too small; we rented at least sizeof(int).
throw ApplicationStateGuard.Unreachable();
}

await _messageBuffer.WriteAsync(bytes.AsMemory(0, sizeof(int)), cancellationToken).ConfigureAwait(false);
}
finally
{
ArrayPool<byte>.Shared.Return(bytes);
}
#else
await _messageBuffer.WriteAsync(BitConverter.GetBytes(sizeOfTheWholeMessage), 0, sizeof(int), cancellationToken).ConfigureAwait(false);
#endif

// Write the serializer ID
#if NET
bytes = ArrayPool<byte>.Shared.Rent(sizeof(int));
try
{
if (!BitConverter.TryWriteBytes(bytes, serializer.Id))
{
throw ApplicationStateGuard.Unreachable();
}

await _messageBuffer.WriteAsync(bytes.AsMemory(0, sizeof(int)), cancellationToken).ConfigureAwait(false);
}
finally
{
ArrayPool<byte>.Shared.Return(bytes);
}
#else
await _messageBuffer.WriteAsync(BitConverter.GetBytes(serializer.Id), 0, sizeof(int), cancellationToken).ConfigureAwait(false);
#endif

// Write the serialized payload
#if NET
await _messageBuffer.WriteAsync(_serializationBuffer.GetBuffer().AsMemory(0, (int)_serializationBuffer.Position), cancellationToken).ConfigureAwait(false);
#else
await _messageBuffer.WriteAsync(_serializationBuffer.GetBuffer(), 0, (int)_serializationBuffer.Position, cancellationToken).ConfigureAwait(false);
#endif

// Send the framed message to the pipe stream
#if NET
await stream.WriteAsync(_messageBuffer.GetBuffer().AsMemory(0, (int)_messageBuffer.Position), cancellationToken).ConfigureAwait(false);
#else
await stream.WriteAsync(_messageBuffer.GetBuffer(), 0, (int)_messageBuffer.Position, cancellationToken).ConfigureAwait(false);
#endif
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
stream.WaitForPipeDrain();
}
}
finally
{
_messageBuffer.Position = 0;
_serializationBuffer.Position = 0;
}
}

/// <summary>
/// Reads one complete framed message from <paramref name="stream"/>, deserializes it, and returns
/// the result. Returns <see langword="null"/> when the stream reaches EOF (peer disconnected).
/// </summary>
#if !MTP_MSBUILD_TASKS
[UnsupportedOSPlatform("browser")]
#endif
protected async Task<object?> ReadNextMessageAsync(PipeStream stream, CancellationToken cancellationToken)
{
_messageBuffer.Position = 0;

try
{
// Read the 4-byte size header. PipeStream.ReadAsync may return fewer bytes than requested
// (e.g. in byte-mode pipes), so we must loop until we've accumulated the full header or hit EOF.
int headerBytesRead = 0;
while (headerBytesRead < sizeof(int))
{
#if NET
int n = await stream.ReadAsync(_readBuffer.AsMemory(headerBytesRead, sizeof(int) - headerBytesRead), cancellationToken).ConfigureAwait(false);
#else
int n = await stream.ReadAsync(_readBuffer, headerBytesRead, sizeof(int) - headerBytesRead, cancellationToken).ConfigureAwait(false);
#endif
if (n == 0)
{
// EOF – peer disconnected (cleanly if headerBytesRead == 0, mid-header otherwise);
// caller decides how to handle this.
return null;
}

headerBytesRead += n;
}

int currentMessageSize = BitConverter.ToInt32(_readBuffer, 0);
int missingBytesToReadOfWholeMessage = currentMessageSize;

// Read the message body in chunks, using _readBuffer as a transfer buffer.
while (missingBytesToReadOfWholeMessage > 0)
{
int toRead = Math.Min(_readBuffer.Length, missingBytesToReadOfWholeMessage);
#if NET
int n = await stream.ReadAsync(_readBuffer.AsMemory(0, toRead), cancellationToken).ConfigureAwait(false);
#else
int n = await stream.ReadAsync(_readBuffer, 0, toRead, cancellationToken).ConfigureAwait(false);
#endif
if (n == 0)
{
// EOF mid-message – treat the same as a clean disconnect.
return null;
}

#if NET
await _messageBuffer.WriteAsync(_readBuffer.AsMemory(0, n), cancellationToken).ConfigureAwait(false);
#else
await _messageBuffer.WriteAsync(_readBuffer, 0, n, cancellationToken).ConfigureAwait(false);
#endif
missingBytesToReadOfWholeMessage -= n;
}

// Full message received – deserialize and return
_messageBuffer.Position = 0;
int serializerId = BitConverter.ToInt32(_messageBuffer.GetBuffer(), 0);
INamedPipeSerializer namedPipeSerializer = GetSerializer(serializerId);
_messageBuffer.Position += sizeof(int); // skip the serializer ID
return namedPipeSerializer.Deserialize(_messageBuffer);
}
finally
{
_messageBuffer.Position = 0;
}
}

/// <summary>Disposes the shared serialization and message buffers.</summary>
protected void DisposeBuffers()
{
_serializationBuffer.Dispose();
_messageBuffer.Dispose();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace Microsoft.Testing.Platform.IPC;
#if !MTP_MSBUILD_TASKS
[UnsupportedOSPlatform("browser")]
#endif
internal sealed class NamedPipeClient : NamedPipeBase, IClient
internal sealed class NamedPipeClient : NamedPipeConnectionBase, IClient
{
private const PipeOptions AsyncCurrentUserPipeOptions = PipeOptions.Asynchronous
#if NET
Expand Down
Loading
Loading