Skip to content

Commit 5226f3c

Browse files
drewnoakesCopilot
andauthored
Make TranslationLayer Native AOT-compatible (#16045)
* Make TranslationLayer NativeAOT-compatible The `Microsoft.TestPlatform.VsTestConsole.TranslationLayer` and `Microsoft.TestPlatform.CommunicationUtilities` assemblies are loaded in-process by NativeAOT consumers (e.g. C# Dev Kit). NativeAOT disables reflection-based `System.Text.Json` serialization by default, causing the vstest wire protocol to fail silently during the TCP handshake and test discovery. ## Changes ### Source-generated `JsonSerializerContext` Add `TestPlatformJsonContext` with `[JsonSerializable]` attributes for every type that crosses the wire: payload DTOs, envelope DTOs, collection types, and `PayloadedMessage<T>` for each concrete `T` used by `VsTestConsoleRequestSender`. Set `TypeInfoResolver` on the base `JsonSerializerOptions` so STJ has compile-time metadata available without runtime reflection. ### AOT-safe serialization converters - **`TestObjectBaseConverterFactory`**: replace `MakeGenericType` + `Activator.CreateInstance` with a singleton non-generic converter. - **`ObjectConverter.Write`**, **`ObjectDictionaryConverter.Write`**, **`TestObjectConverter.Write`**, **`TestCaseConverterV2.Write`**, **`TestResultConverterV2.Write`**: replace `JsonSerializer.Serialize(writer, value, value.GetType(), options)` with direct primitive writes via a centralized `WritePropertyValue()` helper that handles string, int, long, double, float, bool, DateTimeOffset, DateTime, Guid, Uri, with `ToString()` fallback. ### Envelope DTO fixes - Change `MessageEnvelope.Payload` and `VersionedMessageEnvelope.Payload` from `object?` to `JsonElement?` so pre-serialized payloads are embedded as nested JSON objects rather than double-encoded strings. - Make `MessageEnvelope`, `VersionedMessageEnvelope`, `VersionedMessageForSerialization`, and `PayloadedMessage<T>` `internal` (were `private`) so the source generator can reference them. - Use `JsonSerializer.SerializeToElement(payload, payload.GetType(), options)` to serialize payloads with runtime type dispatch before embedding in envelopes. ### AOT/trim analyzers enabled Set `IsAotCompatible=true`, `EnableTrimAnalyzer=true`, and `EnableAotAnalyzer=true` on the `net8.0` TFM for both `TranslationLayer` and `CommunicationUtilities` projects. Both build with zero IL2xxx/IL3xxx warnings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix AoT compatibility issues in serialization layer - Chain source-gen context with DefaultJsonTypeInfoResolver so types not covered by the source-gen context fall back to reflection in non-AoT builds, while NativeAOT consumers use the source-gen metadata. - Pass JsonSerializerOptions through WritePropertyValue so the default case uses the resolver chain instead of bare reflection. - Add ExceptionConverter to handle Exception de/serialization properly since source-gen uses the parameterless constructor which cannot populate the read-only Message/StackTrace properties. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix TestObjectBaseConverter to instantiate the requested type The converter was always creating a TestCase regardless of typeToConvert, which caused InvalidCastException when deserializing other TestObject subclasses. Now uses Activator.CreateInstance(typeToConvert) for concrete types, falling back to TestCase for abstract types. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Suppress IL2026/IL3050 AoT warnings via StjSafe wrapper All JsonSerializer.Serialize/Deserialize calls in converters and JsonDataSerializer now go through StjSafe, which centralizes the [UnconditionalSuppressMessage] attributes. This prevents the NativeAOT linker in consuming projects (e.g., C# DevKit) from emitting IL2026 trimming warnings for these call sites. The suppressions are safe because all JsonSerializerOptions instances are configured with TestPlatformJsonContext (source-gen) as the primary TypeInfoResolver. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback - ExceptionConverter: restore HResult and Source in Read (writable properties), document type-erasure and StackTrace limitations. - WritePropertyValue: add missing primitive cases for short, ushort, uint, ulong, byte, sbyte, decimal, char, and enums to avoid falling through to SerializeToElement for types not in the source-gen context. - TestObjectBaseConverter: revert Activator.CreateInstance to new TestCase() to avoid reflection incompatible with NativeAOT; document that this path only handles generic property bags on the wire. - SerializePayloadCore: document why the fast path was intentionally collapsed (object? Payload is incompatible with AoT). - TestPlatformJsonContext: add maintenance checklist for new payload types, clarify why Dictionary<string,object> entries are needed alongside the custom converters. - StjSafe: add DEBUG assert verifying options.TypeInfoResolver is configured, to catch misuse as the codebase evolves. - Update TestObjectConverterTests to deserialize as TestObject (not TestableTestObject) and verify custom properties by ID rather than counting all properties (TestCase carrier has built-in properties). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Preserve exception type name and stack trace via RemoteException Introduce RemoteException that carries the original ClassName and StackTraceString from the remote process. ToString() renders the full original diagnostic output (type name, message, stack trace, inner exception chain) so that callers see the same information they would from the original exception. The ExceptionConverter Write path also handles round-tripping: if the exception is already a RemoteException, it preserves the stored ClassName and RemoteStackTrace rather than emitting the wrapper type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Collapse duplicate SerializePayloadCore branches The fast-path branch was already doing the same thing as the slow-path after the AoT changes (both serialize to JsonElement then embed in envelope). Merge into a single code path and remove the now-unused DisableFastJson field and Utilities using. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add NativeAOT compatibility integration test Adds a test asset (NativeAotTranslationLayerConsumer) that is a minimal console app referencing the TranslationLayer with PublishAot=true. The NativeAotCompatibilityTests test publishes this app with NativeAOT and asserts that no IL2026/IL3050 linker warnings originate from the CommunicationUtilities.Serialization namespace. Pre-existing warnings from Jsonite, ObjectModel, and DefaultJsonTypeInfoResolver are excluded. Marked with [TestCategory("Compatibility")] so it doesn't run in every PR build (NativeAOT publish takes ~4 minutes). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address second round of PR review feedback - Narrow TestObjectBaseConverter.CanConvert to only typeof(TestObject), not all TestObject subtypes. TestCase/TestResult have their own converters and no other subtypes flow through the wire protocol. - Remove Enum special case from WritePropertyValue — let enums fall through to SerializeToElement which respects JsonSerializerOptions (avoids ulong overflow and bypassing custom enum converters). - Test asset: remove unused usings, touch VsTestConsoleWrapper type and payload types so the linker analyzes the full TranslationLayer graph. - Tests: use Assert.IsNotNull before TestProperty.Find instead of null-forgiving operator. - Tests: serialize as TestObject (declared wire type) not TestableTestObject. - Integration test: add second WaitForExit() call to drain async output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Jsonite deserialization of abstract TestObject type The Jsonite ConvertTo method excluded typeof(TestObject) from the DeserializeTestObject handler, causing it to fall through to the generic CreateInstance path which throws MemberAccessException for abstract types. Fix by removing the exclusion and using TestCase as a concrete property-bag carrier when the target type is abstract, matching the approach used in the STJ TestObjectBaseConverter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add round-trip test for TestObject serialization Verifies that custom properties survive a serialize-as-concrete-subtype then deserialize-as-abstract-TestObject round trip. Covers both the STJ path (net11.0) and Jsonite path (net481). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add explicit WritePropertyValue cases for enums, collections, and dictionaries Reduces reliance on the StjSafe.SerializeToElement fallback which requires runtime type metadata that may not be available under NativeAOT. All known property value types used in the wire protocol now have explicit handlers: - Enum (as numeric value) - TimeSpan (as string) - string[] (as JSON string array) - KeyValuePair<string, string>[] (as JSON array of Key/Value objects) - IDictionary (as JSON object) - IEnumerable (as JSON array) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 78539ec commit 5226f3c

24 files changed

Lines changed: 802 additions & 107 deletions

src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,24 @@
44
#if NETCOREAPP
55
using System;
66
using System.Collections.Generic;
7+
using System.Diagnostics.CodeAnalysis;
78
using System.Linq;
89
using System.Text.Encodings.Web;
910
using System.Text.Json;
1011
using System.Text.Json.Serialization;
12+
using System.Text.Json.Serialization.Metadata;
1113

1214
using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Serialization;
1315
using Microsoft.VisualStudio.TestPlatform.Common.DataCollection;
1416
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client;
15-
using Microsoft.VisualStudio.TestPlatform.Utilities;
1617

1718
namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities;
1819

1920
public partial class JsonDataSerializer
2021
{
21-
private static readonly bool DisableFastJson = FeatureFlag.Instance.IsSet(FeatureFlag.VSTEST_DISABLE_FASTER_JSON_SERIALIZATION);
22-
2322
private static readonly JsonSerializerOptions PayloadOptionsV1; // payload options for version <= 1
2423
private static readonly JsonSerializerOptions PayloadOptionsV2; // payload options for version >= 2
25-
private static readonly JsonSerializerOptions FastOptions; // options for faster json
24+
private static readonly JsonSerializerOptions FastOptions; // options for fast deserialization
2625
private static readonly JsonSerializerOptions DefaultOptions; // generic options
2726

2827
static JsonDataSerializer()
@@ -41,6 +40,7 @@ static JsonDataSerializer()
4140
DefaultOptions.Converters.Add(new TestProcessAttachDebuggerPayloadConverter());
4241
DefaultOptions.Converters.Add(new TestSessionInfoConverter());
4342
DefaultOptions.Converters.Add(new DiscoveryCriteriaConverter());
43+
DefaultOptions.Converters.Add(new ExceptionConverter());
4444

4545
// V2 options: clone DefaultOptions and add V2-specific converters
4646
PayloadOptionsV2 = new JsonSerializerOptions(DefaultOptions);
@@ -62,6 +62,7 @@ static JsonDataSerializer()
6262
FastOptions = new JsonSerializerOptions(PayloadOptionsV2);
6363
}
6464

65+
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "DefaultJsonTypeInfoResolver is only used as a fallback for non-AoT builds.")]
6566
private static JsonSerializerOptions CreateBaseOptions() => new()
6667
{
6768
PropertyNameCaseInsensitive = true,
@@ -74,6 +75,11 @@ static JsonDataSerializer()
7475
NumberHandling = JsonNumberHandling.AllowReadingFromString,
7576
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
7677
ReferenceHandler = ReferenceHandler.IgnoreCycles,
78+
// Chain the source-generated context (for NativeAOT where reflection is
79+
// disabled) with the default reflection-based resolver (for types not
80+
// covered by the source-gen context in non-AoT builds). Under NativeAOT,
81+
// DefaultJsonTypeInfoResolver is a no-op for trimmed types.
82+
TypeInfoResolver = JsonTypeInfoResolver.Combine(TestPlatformJsonContext.Default, new DefaultJsonTypeInfoResolver()),
7783
};
7884

7985
private static partial (int version, string? messageType) ParseHeaderFromJson(string rawMessage)
@@ -105,7 +111,7 @@ private static partial (int version, string? messageType) ParseHeaderFromJson(st
105111
using var doc = JsonDocument.Parse(message.RawMessage!);
106112
if (doc.RootElement.TryGetProperty("Payload", out var payloadElement))
107113
{
108-
result = JsonSerializer.Deserialize<T>(payloadElement, payloadOptions);
114+
result = StjSafe.Deserialize<T>(payloadElement, payloadOptions);
109115
}
110116
else
111117
{
@@ -180,24 +186,20 @@ private static partial string SerializeMessageCore(string? messageType)
180186

181187
private static partial string SerializePayloadCore(string? messageType, object? payload, int version)
182188
{
189+
if (payload is null)
190+
return string.Empty;
191+
183192
var payloadOptions = GetPayloadOptions(version);
184-
// Fast json is only equivalent to the serialization that is used for protocol version 2 and upwards (or more precisely for the paths that use PayloadOptionsV2)
185-
// so when we resolved the old options we should use non-fast path.
186-
if (DisableFastJson || payloadOptions == PayloadOptionsV1)
187-
{
188-
if (payload is null)
189-
return string.Empty;
190193

191-
var serializedPayload = JsonSerializer.SerializeToElement(payload, payloadOptions);
194+
// Serialize payload to JsonElement first using the versioned options (which have the
195+
// custom converters), then embed in the envelope. This two-step approach is required
196+
// for NativeAOT: serializing object? Payload directly would require STJ to resolve
197+
// the runtime type polymorphically via reflection.
198+
var serializedPayload = StjSafe.SerializeToElement(payload, payload.GetType(), payloadOptions);
192199

193-
return version > 1 ?
194-
Serialize(DefaultOptions, new VersionedMessageEnvelope { MessageType = messageType, Version = version, Payload = serializedPayload }) :
195-
Serialize(DefaultOptions, new MessageEnvelope { MessageType = messageType, Payload = serializedPayload });
196-
}
197-
else
198-
{
199-
return Serialize(FastOptions, new VersionedMessageForSerialization { MessageType = messageType, Version = version, Payload = payload });
200-
}
200+
return version > 1 ?
201+
Serialize(DefaultOptions, new VersionedMessageEnvelope { MessageType = messageType, Version = version, Payload = serializedPayload }) :
202+
Serialize(DefaultOptions, new MessageEnvelope { MessageType = messageType, Payload = serializedPayload });
201203
}
202204

203205
private static partial string SerializeCore<T>(T data, int version)
@@ -208,7 +210,7 @@ private static partial string SerializeCore<T>(T data, int version)
208210

209211
private static T? DeserializeObjectFast<T>(string value)
210212
{
211-
return JsonSerializer.Deserialize<T>(value, FastOptions);
213+
return StjSafe.Deserialize<T>(value, FastOptions);
212214
}
213215

214216
/// <summary>
@@ -220,7 +222,7 @@ private static partial string SerializeCore<T>(T data, int version)
220222
/// <returns>Serialized data.</returns>
221223
private static string Serialize<T>(JsonSerializerOptions options, T data)
222224
{
223-
return JsonSerializer.Serialize(data, options);
225+
return StjSafe.Serialize(data, options);
224226
}
225227

226228
/// <summary>
@@ -232,7 +234,7 @@ private static string Serialize<T>(JsonSerializerOptions options, T data)
232234
/// <returns>Deserialized data.</returns>
233235
private static T? Deserialize<T>(JsonSerializerOptions options, string data)
234236
{
235-
return JsonSerializer.Deserialize<T>(data, options);
237+
return StjSafe.Deserialize<T>(data, options);
236238
}
237239

238240
private static JsonSerializerOptions GetPayloadOptions(int? version)
@@ -258,7 +260,7 @@ private static JsonSerializerOptions GetPayloadOptions(int? version)
258260
/// This grabs payload from the message, we already know version and message type.
259261
/// </summary>
260262
/// <typeparam name="T"></typeparam>
261-
private class PayloadedMessage<T>
263+
internal class PayloadedMessage<T>
262264
{
263265
public T? Payload { get; set; }
264266
}
@@ -267,31 +269,31 @@ private class PayloadedMessage<T>
267269
/// Serialization-only DTO for building the JSON wire format (without Version).
268270
/// NOT a Message — this is never returned to callers.
269271
/// </summary>
270-
private class MessageEnvelope
272+
internal class MessageEnvelope
271273
{
272274
public string? MessageType { get; set; }
273275

274276
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
275-
public object? Payload { get; set; }
277+
public JsonElement? Payload { get; set; }
276278
}
277279

278280
/// <summary>
279281
/// Serialization-only DTO for building the JSON wire format (with Version).
280282
/// NOT a Message — this is never returned to callers.
281283
/// </summary>
282-
private class VersionedMessageEnvelope
284+
internal class VersionedMessageEnvelope
283285
{
284286
public int Version { get; set; }
285287
public string? MessageType { get; set; }
286288

287289
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
288-
public object? Payload { get; set; }
290+
public JsonElement? Payload { get; set; }
289291
}
290292

291293
/// <summary>
292294
/// For serialization directly into string, without first converting to JsonElement, and then from JsonElement to string.
293295
/// </summary>
294-
private class VersionedMessageForSerialization
296+
internal class VersionedMessageForSerialization
295297
{
296298
/// <summary>
297299
/// Gets or sets the version of the message

src/Microsoft.TestPlatform.CommunicationUtilities/Microsoft.TestPlatform.CommunicationUtilities.csproj

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@
55
<TargetFrameworks>$(NetFrameworkMinimum);$(ExtensionTargetFrameworks);$(NetCoreAppMinimum)</TargetFrameworks>
66
<IsTestProject>false</IsTestProject>
77
</PropertyGroup>
8+
9+
<!-- NativeAOT / trimming compatibility analysis (net8.0+ only) -->
10+
<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
11+
<IsAotCompatible>true</IsAotCompatible>
12+
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
13+
<EnableAotAnalyzer>true</EnableAotAnalyzer>
14+
<!-- Downgrade pre-existing AOT/trim warnings from errors to warnings.
15+
Our new code is AOT-clean; these are from Jsonite and legacy converters. -->
16+
<WarningsNotAsErrors>$(WarningsNotAsErrors);IL2026;IL2057;IL2067;IL3050</WarningsNotAsErrors>
17+
</PropertyGroup>
818
<ItemGroup>
919
<ProjectReference Include="..\Microsoft.TestPlatform.CoreUtilities\Microsoft.TestPlatform.CoreUtilities.csproj" />
1020
<ProjectReference Include="..\Microsoft.TestPlatform.ObjectModel\Microsoft.TestPlatform.ObjectModel.csproj" />

src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/AfterTestRunEndResultConverter.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public override void Write(Utf8JsonWriter writer, AfterTestRunEndResult value, J
5252
{
5353
if (element.TryGetProperty(name, out var prop) && prop.ValueKind != JsonValueKind.Null)
5454
{
55-
return JsonSerializer.Deserialize<T>(prop.GetRawText(), options);
55+
return StjSafe.Deserialize<T>(prop.GetRawText(), options);
5656
}
5757

5858
return default;
@@ -61,7 +61,7 @@ public override void Write(Utf8JsonWriter writer, AfterTestRunEndResult value, J
6161
private static void WriteProperty<T>(Utf8JsonWriter writer, string name, T value, JsonSerializerOptions options)
6262
{
6363
writer.WritePropertyName(name);
64-
JsonSerializer.Serialize(writer, value, options);
64+
StjSafe.Serialize(writer, value, options);
6565
}
6666
}
6767

src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/AttachmentConverters.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ internal class AttachmentSetConverter : JsonConverter<AttachmentSet>
3232
{
3333
if (attachment.ValueKind != JsonValueKind.Null)
3434
{
35-
attachmentSet.Attachments.Add(JsonSerializer.Deserialize<UriDataAttachment>(attachment.GetRawText(), options)!);
35+
attachmentSet.Attachments.Add(StjSafe.Deserialize<UriDataAttachment>(attachment.GetRawText(), options)!);
3636
}
3737
}
3838
}
@@ -46,7 +46,7 @@ public override void Write(Utf8JsonWriter writer, AttachmentSet value, JsonSeria
4646
writer.WriteString("Uri", value.Uri.OriginalString);
4747
writer.WriteString("DisplayName", value.DisplayName);
4848
writer.WritePropertyName("Attachments");
49-
JsonSerializer.Serialize(writer, value.Attachments, options);
49+
StjSafe.Serialize(writer, value.Attachments, options);
5050
writer.WriteEndObject();
5151
}
5252
}

src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/DiscoveryCriteriaConverter.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ public override void Write(Utf8JsonWriter writer, DiscoveryCriteria value, JsonS
7575
{
7676
if (element.TryGetProperty(name, out var prop) && prop.ValueKind != JsonValueKind.Null)
7777
{
78-
return JsonSerializer.Deserialize<T>(prop.GetRawText(), options);
78+
return StjSafe.Deserialize<T>(prop.GetRawText(), options);
7979
}
8080

8181
return default;
@@ -84,7 +84,7 @@ public override void Write(Utf8JsonWriter writer, DiscoveryCriteria value, JsonS
8484
private static void WriteProperty<T>(Utf8JsonWriter writer, string name, T value, JsonSerializerOptions options)
8585
{
8686
writer.WritePropertyName(name);
87-
JsonSerializer.Serialize(writer, value, options);
87+
StjSafe.Serialize(writer, value, options);
8888
}
8989
}
9090

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
#if NETCOREAPP
5+
6+
using System;
7+
using System.Text;
8+
using System.Text.Json;
9+
using System.Text.Json.Serialization;
10+
11+
namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Serialization;
12+
13+
/// <summary>
14+
/// JSON converter for <see cref="Exception"/> that handles the read-only properties
15+
/// (<c>Message</c>, <c>StackTrace</c>) which STJ's source-generated metadata cannot populate
16+
/// via the parameterless constructor.
17+
/// <para>
18+
/// On deserialization, produces a <see cref="RemoteException"/> that preserves the original
19+
/// type name, message, stack trace, and inner exception. The original exception type is erased
20+
/// (all exceptions materialize as <see cref="RemoteException"/>) but <c>ToString()</c> renders
21+
/// the full original information including type name and stack trace.
22+
/// </para>
23+
/// </summary>
24+
internal class ExceptionConverter : JsonConverter<Exception>
25+
{
26+
/// <inheritdoc/>
27+
public override Exception? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
28+
{
29+
if (reader.TokenType == JsonTokenType.Null)
30+
{
31+
return null;
32+
}
33+
34+
using var doc = JsonDocument.ParseValue(ref reader);
35+
var root = doc.RootElement;
36+
37+
string? className = root.TryGetProperty("ClassName", out var clsProp) && clsProp.ValueKind == JsonValueKind.String
38+
? clsProp.GetString()
39+
: null;
40+
41+
string? message = root.TryGetProperty("Message", out var msgProp) && msgProp.ValueKind != JsonValueKind.Null
42+
? msgProp.GetString()
43+
: null;
44+
45+
string? stackTrace = root.TryGetProperty("StackTraceString", out var stProp) && stProp.ValueKind == JsonValueKind.String
46+
? stProp.GetString()
47+
: null;
48+
49+
Exception? innerException = null;
50+
if (root.TryGetProperty("InnerException", out var innerProp) && innerProp.ValueKind != JsonValueKind.Null)
51+
{
52+
innerException = StjSafe.Deserialize<Exception>(innerProp.GetRawText(), options);
53+
}
54+
55+
var exception = new RemoteException(className, message, stackTrace, innerException);
56+
57+
if (root.TryGetProperty("HResult", out var hresultProp) && hresultProp.ValueKind == JsonValueKind.Number)
58+
{
59+
exception.HResult = hresultProp.GetInt32();
60+
}
61+
62+
if (root.TryGetProperty("Source", out var sourceProp) && sourceProp.ValueKind == JsonValueKind.String)
63+
{
64+
exception.Source = sourceProp.GetString();
65+
}
66+
67+
return exception;
68+
}
69+
70+
/// <inheritdoc/>
71+
public override void Write(Utf8JsonWriter writer, Exception value, JsonSerializerOptions options)
72+
{
73+
writer.WriteStartObject();
74+
75+
// For RemoteException (already deserialized once), preserve the original ClassName.
76+
writer.WriteString("ClassName", value is RemoteException remote
77+
? remote.ClassName
78+
: value.GetType().FullName);
79+
writer.WriteString("Message", value.Message);
80+
writer.WriteString("StackTraceString", value is RemoteException re
81+
? re.RemoteStackTrace
82+
: value.StackTrace);
83+
writer.WriteString("Source", value.Source);
84+
writer.WriteNumber("HResult", value.HResult);
85+
86+
writer.WritePropertyName("InnerException");
87+
if (value.InnerException is null)
88+
{
89+
writer.WriteNullValue();
90+
}
91+
else
92+
{
93+
StjSafe.Serialize(writer, value.InnerException, options);
94+
}
95+
96+
writer.WriteEndObject();
97+
}
98+
}
99+
100+
/// <summary>
101+
/// Exception that preserves diagnostic information from a remotely-serialized exception,
102+
/// including the original type name and stack trace. Since the original exception type may
103+
/// not be available (or may be trimmed under NativeAOT), this type acts as a carrier that
104+
/// faithfully reproduces the original <c>ToString()</c> output.
105+
/// </summary>
106+
internal sealed class RemoteException : Exception
107+
{
108+
/// <summary>
109+
/// Gets the fully qualified name of the original exception type (e.g.
110+
/// <c>"System.InvalidOperationException"</c>).
111+
/// </summary>
112+
public string? ClassName { get; }
113+
114+
/// <summary>
115+
/// Gets the original stack trace string as captured from the remote process.
116+
/// </summary>
117+
public string? RemoteStackTrace { get; }
118+
119+
public RemoteException(string? className, string? message, string? stackTrace, Exception? innerException)
120+
: base(message, innerException)
121+
{
122+
ClassName = className;
123+
RemoteStackTrace = stackTrace;
124+
}
125+
126+
public override string ToString()
127+
{
128+
var sb = new StringBuilder();
129+
sb.Append(ClassName ?? nameof(RemoteException));
130+
if (!string.IsNullOrEmpty(Message))
131+
{
132+
sb.Append(": ").Append(Message);
133+
}
134+
135+
if (InnerException is not null)
136+
{
137+
sb.Append(" ---> ").Append(InnerException).AppendLine()
138+
.Append(" --- End of inner exception stack trace ---");
139+
}
140+
141+
if (!string.IsNullOrEmpty(RemoteStackTrace))
142+
{
143+
sb.AppendLine().Append(RemoteStackTrace);
144+
}
145+
146+
return sb.ToString();
147+
}
148+
}
149+
150+
#endif

0 commit comments

Comments
 (0)