Skip to content

Commit fd9886d

Browse files
authored
Add option to ignore reference cycles on serialization (#46101)
* Add option to ignore reference cycles on serialization * Fix whitespace * Combine IsValueType check on WriteCoreAsObject * Add missing null-forgiving operator * Fix perf regression by using ReferenceHandlerStrategy field in JsonSerializerOptions * Address suggestions * Rename API to IgnoreCycles * Add missing letter to test class name * Fix CI issue with nullable annotation
1 parent 77c939c commit fd9886d

20 files changed

+652
-44
lines changed

src/libraries/System.Text.Json/ref/System.Text.Json.cs

+1
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,7 @@ public abstract partial class ReferenceHandler
559559
{
560560
protected ReferenceHandler() { }
561561
public static System.Text.Json.Serialization.ReferenceHandler Preserve { get { throw null; } }
562+
public static System.Text.Json.Serialization.ReferenceHandler IgnoreCycles { get { throw null; } }
562563
public abstract System.Text.Json.Serialization.ReferenceResolver CreateResolver();
563564
}
564565
public sealed partial class ReferenceHandler<T> : System.Text.Json.Serialization.ReferenceHandler where T : System.Text.Json.Serialization.ReferenceResolver, new()

src/libraries/System.Text.Json/src/System.Text.Json.csproj

+4
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@
6464
<Compile Include="System\Text\Json\Serialization\Attributes\JsonIncludeAttribute.cs" />
6565
<Compile Include="System\Text\Json\Serialization\Attributes\JsonNumberHandlingAttribute.cs" />
6666
<Compile Include="System\Text\Json\Serialization\Attributes\JsonPropertyNameAttribute.cs" />
67+
<Compile Include="System\Text\Json\Serialization\IgnoreReferenceResolver.cs" />
68+
<Compile Include="System\Text\Json\Serialization\ReferenceEqualsWrapper.cs" />
6769
<Compile Include="System\Text\Json\Serialization\ClassType.cs" />
6870
<Compile Include="System\Text\Json\Serialization\ConverterList.cs" />
6971
<Compile Include="System\Text\Json\Serialization\Converters\Collection\ArrayConverter.cs" />
@@ -128,6 +130,7 @@
128130
<Compile Include="System\Text\Json\Serialization\Converters\Value\UInt64Converter.cs" />
129131
<Compile Include="System\Text\Json\Serialization\Converters\Value\UriConverter.cs" />
130132
<Compile Include="System\Text\Json\Serialization\Converters\Value\VersionConverter.cs" />
133+
<Compile Include="System\Text\Json\Serialization\IgnoreReferenceHandler.cs" />
131134
<Compile Include="System\Text\Json\Serialization\JsonCamelCaseNamingPolicy.cs" />
132135
<Compile Include="System\Text\Json\Serialization\JsonClassInfo.cs" />
133136
<Compile Include="System\Text\Json\Serialization\JsonClassInfo.Cache.cs" />
@@ -174,6 +177,7 @@
174177
<Compile Include="System\Text\Json\Serialization\ReadStackFrame.cs" />
175178
<Compile Include="System\Text\Json\Serialization\ReferenceHandler.cs" />
176179
<Compile Include="System\Text\Json\Serialization\ReferenceHandlerOfT.cs" />
180+
<Compile Include="System\Text\Json\Serialization\ReferenceHandlingStrategy.cs" />
177181
<Compile Include="System\Text\Json\Serialization\ReferenceResolver.cs" />
178182
<Compile Include="System\Text\Json\Serialization\ReflectionEmitMemberAccessor.cs" />
179183
<Compile Include="System\Text\Json\Serialization\ReflectionMemberAccessor.cs" />

src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/DictionaryDefaultConverter.cs

+2-3
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ internal sealed override bool OnTryRead(
133133
}
134134

135135
// Handle the metadata properties.
136-
bool preserveReferences = options.ReferenceHandler != null;
136+
bool preserveReferences = options.ReferenceHandlingStrategy == ReferenceHandlingStrategy.Preserve;
137137
if (preserveReferences && state.Current.ObjectState < StackFrameObjectState.PropertyValue)
138138
{
139139
if (JsonSerializer.ResolveMetadataForJsonObject<TCollection>(ref reader, ref state, options))
@@ -272,8 +272,7 @@ internal sealed override bool OnTryWrite(
272272
{
273273
state.Current.ProcessedStartToken = true;
274274
writer.WriteStartObject();
275-
276-
if (options.ReferenceHandler != null)
275+
if (options.ReferenceHandlingStrategy == ReferenceHandlingStrategy.Preserve)
277276
{
278277
if (JsonSerializer.WriteReferenceForObject(this, dictionary, ref state, writer) == MetadataPropertyName.Ref)
279278
{

src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IEnumerableDefaultConverter.cs

+6-7
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ internal override bool OnTryRead(
9090
{
9191
// Slower path that supports continuation and preserved references.
9292

93-
bool preserveReferences = options.ReferenceHandler != null;
93+
bool preserveReferences = options.ReferenceHandlingStrategy == ReferenceHandlingStrategy.Preserve;
9494
if (state.Current.ObjectState == StackFrameObjectState.None)
9595
{
9696
if (reader.TokenType == JsonTokenType.StartArray)
@@ -236,12 +236,7 @@ internal sealed override bool OnTryWrite(
236236
if (!state.Current.ProcessedStartToken)
237237
{
238238
state.Current.ProcessedStartToken = true;
239-
240-
if (options.ReferenceHandler == null)
241-
{
242-
writer.WriteStartArray();
243-
}
244-
else
239+
if (options.ReferenceHandlingStrategy == ReferenceHandlingStrategy.Preserve)
245240
{
246241
MetadataPropertyName metadata = JsonSerializer.WriteReferenceForCollection(this, value, ref state, writer);
247242
if (metadata == MetadataPropertyName.Ref)
@@ -251,6 +246,10 @@ internal sealed override bool OnTryWrite(
251246

252247
state.Current.MetadataPropertyName = metadata;
253248
}
249+
else
250+
{
251+
writer.WriteStartArray();
252+
}
254253

255254
state.Current.DeclaredJsonPropertyInfo = state.Current.JsonClassInfo.ElementClassInfo!.PropertyInfoForClassInfo;
256255
}

src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Object/ObjectDefaultConverter.cs

+3-5
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ internal override bool OnTryRead(ref Utf8JsonReader reader, Type typeToConvert,
7575
// Handle the metadata properties.
7676
if (state.Current.ObjectState < StackFrameObjectState.PropertyValue)
7777
{
78-
if (options.ReferenceHandler != null)
78+
if (options.ReferenceHandlingStrategy == ReferenceHandlingStrategy.Preserve)
7979
{
8080
if (JsonSerializer.ResolveMetadataForJsonObject<T>(ref reader, ref state, options))
8181
{
@@ -233,8 +233,7 @@ internal sealed override bool OnTryWrite(
233233
if (!state.SupportContinuation)
234234
{
235235
writer.WriteStartObject();
236-
237-
if (options.ReferenceHandler != null)
236+
if (options.ReferenceHandlingStrategy == ReferenceHandlingStrategy.Preserve)
238237
{
239238
if (JsonSerializer.WriteReferenceForObject(this, objectValue, ref state, writer) == MetadataPropertyName.Ref)
240239
{
@@ -289,8 +288,7 @@ internal sealed override bool OnTryWrite(
289288
if (!state.Current.ProcessedStartToken)
290289
{
291290
writer.WriteStartObject();
292-
293-
if (options.ReferenceHandler != null)
291+
if (options.ReferenceHandlingStrategy == ReferenceHandlingStrategy.Preserve)
294292
{
295293
if (JsonSerializer.WriteReferenceForObject(this, objectValue, ref state, writer) == MetadataPropertyName.Ref)
296294
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace System.Text.Json.Serialization
5+
{
6+
internal sealed class IgnoreReferenceHandler : ReferenceHandler
7+
{
8+
public IgnoreReferenceHandler() => HandlingStrategy = ReferenceHandlingStrategy.IgnoreCycles;
9+
10+
public override ReferenceResolver CreateResolver() => new IgnoreReferenceResolver();
11+
}
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Collections.Generic;
5+
using System.Diagnostics;
6+
7+
namespace System.Text.Json.Serialization
8+
{
9+
internal sealed class IgnoreReferenceResolver : ReferenceResolver
10+
{
11+
// The stack of references on the branch of the current object graph, used to detect reference cycles.
12+
private Stack<ReferenceEqualsWrapper>? _stackForCycleDetection;
13+
14+
internal override void PopReferenceForCycleDetection()
15+
{
16+
Debug.Assert(_stackForCycleDetection != null);
17+
_stackForCycleDetection.Pop();
18+
}
19+
20+
internal override bool ContainsReferenceForCycleDetection(object value)
21+
=> _stackForCycleDetection?.Contains(new ReferenceEqualsWrapper(value)) ?? false;
22+
23+
internal override void PushReferenceForCycleDetection(object value)
24+
{
25+
var wrappedValue = new ReferenceEqualsWrapper(value);
26+
27+
if (_stackForCycleDetection is null)
28+
{
29+
_stackForCycleDetection = new Stack<ReferenceEqualsWrapper>();
30+
}
31+
32+
Debug.Assert(!_stackForCycleDetection.Contains(wrappedValue));
33+
_stackForCycleDetection.Push(wrappedValue);
34+
}
35+
36+
public override void AddReference(string referenceId, object value) => throw new InvalidOperationException();
37+
38+
public override string GetReference(object value, out bool alreadyExists) => throw new InvalidOperationException();
39+
40+
public override object ResolveReference(string referenceId) => throw new InvalidOperationException();
41+
}
42+
}

src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.WriteCore.cs

+12-3
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,19 @@ internal sealed override bool WriteCoreAsObject(
1111
JsonSerializerOptions options,
1212
ref WriteStack state)
1313
{
14-
// Value types can never have a null except for Nullable<T>.
15-
if (value == null && IsValueType && Nullable.GetUnderlyingType(TypeToConvert) == null)
14+
if (IsValueType)
1615
{
17-
ThrowHelper.ThrowJsonException_DeserializeUnableToConvertValue(TypeToConvert);
16+
// Value types can never have a null except for Nullable<T>.
17+
if (value == null && Nullable.GetUnderlyingType(TypeToConvert) == null)
18+
{
19+
ThrowHelper.ThrowJsonException_DeserializeUnableToConvertValue(TypeToConvert);
20+
}
21+
22+
// Root object is a boxed value type, we need to push it to the reference stack before it gets unboxed here.
23+
if (options.ReferenceHandlingStrategy == ReferenceHandlingStrategy.IgnoreCycles && value != null)
24+
{
25+
state.ReferenceResolver.PushReferenceForCycleDetection(value);
26+
}
1827
}
1928

2029
T actualValue = (T)value!;

src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.cs

+60-21
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,8 @@ internal bool TryRead(ref Utf8JsonReader reader, Type typeToConvert, JsonSeriali
196196
ref reader);
197197
}
198198

199-
if (CanBePolymorphic && options.ReferenceHandler != null && value is JsonElement element)
199+
if (options.ReferenceHandlingStrategy == ReferenceHandlingStrategy.Preserve &&
200+
CanBePolymorphic && value is JsonElement element)
200201
{
201202
// Edge case where we want to lookup for a reference when parsing into typeof(object)
202203
// instead of return `value` as a JsonElement.
@@ -303,23 +304,48 @@ internal bool TryWrite(Utf8JsonWriter writer, in T value, JsonSerializerOptions
303304
ThrowHelper.ThrowJsonException_SerializerCycleDetected(options.EffectiveMaxDepth);
304305
}
305306

307+
if (CanBeNull && !HandleNullOnWrite && IsNull(value))
308+
{
309+
// We do not pass null values to converters unless HandleNullOnWrite is true. Null values for properties were
310+
// already handled in GetMemberAndWriteJson() so we don't need to check for IgnoreNullValues here.
311+
writer.WriteNullValue();
312+
return true;
313+
}
314+
315+
bool ignoreCyclesPopReference = false;
316+
if (options.ReferenceHandlingStrategy == ReferenceHandlingStrategy.IgnoreCycles &&
317+
!IsValueType && !IsNull(value))
318+
{
319+
Debug.Assert(value != null);
320+
ReferenceResolver resolver = state.ReferenceResolver;
321+
322+
// Write null to break reference cycles.
323+
if (resolver.ContainsReferenceForCycleDetection(value))
324+
{
325+
writer.WriteNullValue();
326+
return true;
327+
}
328+
329+
// For boxed reference types: do not push when boxed in order to avoid false positives
330+
// when we run the ContainsReferenceForCycleDetection check for the converter of the unboxed value.
331+
if (!CanBePolymorphic)
332+
{
333+
resolver.PushReferenceForCycleDetection(value);
334+
ignoreCyclesPopReference = true;
335+
}
336+
}
337+
306338
if (CanBePolymorphic)
307339
{
308340
if (value == null)
309341
{
310-
if (!HandleNullOnWrite)
311-
{
312-
writer.WriteNullValue();
313-
}
314-
else
315-
{
316-
Debug.Assert(ClassType == ClassType.Value);
317-
Debug.Assert(!state.IsContinuation);
342+
Debug.Assert(ClassType == ClassType.Value);
343+
Debug.Assert(!state.IsContinuation);
344+
Debug.Assert(HandleNullOnWrite);
318345

319-
int originalPropertyDepth = writer.CurrentDepth;
320-
Write(writer, value, options);
321-
VerifyWrite(originalPropertyDepth, writer);
322-
}
346+
int originalPropertyDepth = writer.CurrentDepth;
347+
Write(writer, value, options);
348+
VerifyWrite(originalPropertyDepth, writer);
323349

324350
return true;
325351
}
@@ -339,18 +365,26 @@ internal bool TryWrite(Utf8JsonWriter writer, in T value, JsonSerializerOptions
339365
JsonConverter jsonConverter = state.Current.InitializeReEntry(type, options);
340366
if (jsonConverter != this)
341367
{
368+
if (options.ReferenceHandlingStrategy == ReferenceHandlingStrategy.IgnoreCycles &&
369+
jsonConverter.IsValueType)
370+
{
371+
// For boxed value types: push the value before it gets unboxed on TryWriteAsObject.
372+
state.ReferenceResolver.PushReferenceForCycleDetection(value);
373+
ignoreCyclesPopReference = true;
374+
}
375+
342376
// We found a different converter; forward to that.
343-
return jsonConverter.TryWriteAsObject(writer, value, options, ref state);
377+
bool success2 = jsonConverter.TryWriteAsObject(writer, value, options, ref state);
378+
379+
if (ignoreCyclesPopReference)
380+
{
381+
state.ReferenceResolver.PopReferenceForCycleDetection();
382+
}
383+
384+
return success2;
344385
}
345386
}
346387
}
347-
else if (CanBeNull && !HandleNullOnWrite && IsNull(value))
348-
{
349-
// We do not pass null values to converters unless HandleNullOnWrite is true. Null values for properties were
350-
// already handled in GetMemberAndWriteJson() so we don't need to check for IgnoreNullValues here.
351-
writer.WriteNullValue();
352-
return true;
353-
}
354388

355389
if (ClassType == ClassType.Value)
356390
{
@@ -390,6 +424,11 @@ internal bool TryWrite(Utf8JsonWriter writer, in T value, JsonSerializerOptions
390424

391425
state.Pop(success);
392426

427+
if (ignoreCyclesPopReference)
428+
{
429+
state.ReferenceResolver.PopReferenceForCycleDetection();
430+
}
431+
393432
return success;
394433
}
395434

src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfoOfT.cs

+9
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,15 @@ public override bool GetMemberAndWriteJson(object obj, ref WriteStack state, Utf
138138
{
139139
T value = Get!(obj);
140140

141+
if (Options.ReferenceHandlingStrategy == ReferenceHandlingStrategy.IgnoreCycles &&
142+
!Converter.IsValueType && value != null &&
143+
state.ReferenceResolver.ContainsReferenceForCycleDetection(value))
144+
{
145+
// If a reference cycle is detected, treat value as null.
146+
value = default!;
147+
Debug.Assert(value == null);
148+
}
149+
141150
if (IgnoreDefaultValuesOnWrite)
142151
{
143152
// If value is null, it is a reference type or nullable<T>.

src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandlePropertyName.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ internal static ReadOnlySpan<byte> GetPropertyName(
7979
unescapedPropertyName = propertyName;
8080
}
8181

82-
if (options.ReferenceHandler != null)
82+
if (options.ReferenceHandlingStrategy == ReferenceHandlingStrategy.Preserve)
8383
{
8484
if (propertyName.Length > 0 && propertyName[0] == '$')
8585
{

src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.cs

+5
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ public JsonSerializerOptions(JsonSerializerOptions options)
8989

9090
Converters = new ConverterList(this, (ConverterList)options.Converters);
9191
EffectiveMaxDepth = options.EffectiveMaxDepth;
92+
ReferenceHandlingStrategy = options.ReferenceHandlingStrategy;
9293

9394
// _classes is not copied as sharing the JsonClassInfo and JsonPropertyInfo caches can result in
9495
// unnecessary references to type metadata, potentially hindering garbage collection on the source options.
@@ -487,9 +488,13 @@ public ReferenceHandler? ReferenceHandler
487488
{
488489
VerifyMutable();
489490
_referenceHandler = value;
491+
ReferenceHandlingStrategy = value?.HandlingStrategy ?? ReferenceHandlingStrategy.None;
490492
}
491493
}
492494

495+
// The cached value used to determine if ReferenceHandler should use Preserve or IgnoreCycles semanitcs or None of them.
496+
internal ReferenceHandlingStrategy ReferenceHandlingStrategy = ReferenceHandlingStrategy.None;
497+
493498
internal MemberAccessor MemberAccessorStrategy
494499
{
495500
get

src/libraries/System.Text.Json/src/System/Text/Json/Serialization/ReadStack.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ public void Initialize(Type type, JsonSerializerOptions options, bool supportCon
8989

9090
Current.NumberHandling = Current.JsonPropertyInfo.NumberHandling;
9191

92-
bool preserveReferences = options.ReferenceHandler != null;
92+
bool preserveReferences = options.ReferenceHandlingStrategy == ReferenceHandlingStrategy.Preserve;
9393
if (preserveReferences)
9494
{
9595
ReferenceResolver = options.ReferenceHandler!.CreateResolver(writing: false);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Runtime.CompilerServices;
5+
6+
namespace System.Text.Json.Serialization
7+
{
8+
internal struct ReferenceEqualsWrapper : IEquatable<ReferenceEqualsWrapper>
9+
{
10+
private object _object;
11+
public ReferenceEqualsWrapper(object obj) => _object = obj;
12+
public override bool Equals(object? obj) => obj is ReferenceEqualsWrapper otherObj && Equals(otherObj);
13+
public bool Equals(ReferenceEqualsWrapper obj) => ReferenceEquals(_object, obj._object);
14+
public override int GetHashCode() => RuntimeHelpers.GetHashCode(_object);
15+
}
16+
}

0 commit comments

Comments
 (0)