Description
Description
You will occasionally get an InvalidCastException
when using Validator
to validate one or more RangeAttribute
from multiple threads.
Reproduction Steps
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
const int repeat = 100_000;
var exceptions = 0;
// Validator loads attributes from TypeDescriptor (which caches instances).
var rangeAttribute = TypeDescriptor.GetProperties(typeof(TestOptions))
.Find(nameof(TestOptions.WaitTime), false)
?.Attributes.OfType<RangeAttribute>().FirstOrDefault();
var rangePropertiesToReset = new List<KeyValuePair<PropertyInfo, object?>>();
foreach (var propertyInfo in typeof(RangeAttribute)
.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
{
if (propertyInfo.CanRead && propertyInfo.CanWrite)
{
// We only have a chance to reproduce when attribute is in its initial state.
rangePropertiesToReset.Add(new(propertyInfo, propertyInfo.GetValue(rangeAttribute)));
}
}
for (var i = 0; i < repeat; i++)
{
Parallel.For(
0,
2,
index =>
{
if (index == 0)
{
// May help to increase on a slower machine (or vice versa).
Thread.SpinWait(50);
}
var options = new TestOptions();
var context = new ValidationContext(options) { MemberName = nameof(TestOptions.WaitTime) };
try
{
Validator.ValidateProperty(options.WaitTime, context);
}
catch (InvalidCastException exception)
{
if (Interlocked.Increment(ref exceptions) == 1)
{
Console.WriteLine(exception);
}
}
});
foreach (var (propertyInfo, propertyValue) in rangePropertiesToReset)
{
propertyInfo.SetValue(rangeAttribute, propertyValue);
}
}
Console.WriteLine();
var frequency = exceptions / (double)repeat;
Console.WriteLine($"{exceptions} exceptions encountered ({frequency:P4})");
class TestOptions
{
[Range(typeof(TimeSpan), "00:00:00", "01:00:00")]
public TimeSpan? WaitTime { get; set; }
}
Expected behavior
Validator members should not throw exceptions when invoked from multiple threads.
Actual behavior
System.InvalidCastException: Unable to cast object of type 'System.TimeSpan' to type 'System.String'.
at System.ComponentModel.DataAnnotations.RangeAttribute.SetupConversion()
at System.ComponentModel.DataAnnotations.RangeAttribute.IsValid(Object value)
at System.ComponentModel.DataAnnotations.ValidationAttribute.IsValid(Object value, ValidationContext validationContext)
at System.ComponentModel.DataAnnotations.ValidationAttribute.GetValidationResult(Object value, ValidationContext validationContext)
at System.ComponentModel.DataAnnotations.Validator.TryValidate(Object value, ValidationContext validationContext, ValidationAttribute attribute, ValidationError& validationError)
at System.ComponentModel.DataAnnotations.Validator.GetValidationErrors(Object value, ValidationContext validationContext, IEnumerable`1 attributes, Boolean breakOnFirstError)
at System.ComponentModel.DataAnnotations.Validator.ValidateProperty(Object value, ValidationContext validationContext)
at Program.<>c__DisplayClass0_0.<<Main>$>b__0(Int32 index) in C:\Users\kylem\Desktop\HelloDataAnnotations\Program.cs:line 48
318 exceptions encountered (0.3180%)
Regression?
No response
Known Workarounds
- Use a lock to synchronize access to
Validator
. - Catch the exception and retry.
Configuration
- .NET SDK 9.0.100
- .NET Runtime 9.0.0
- Windows 11 22631.4602 (x64)
Other information
This is likely to occur in applications using Polly - see bug there.
The exception was previously reported as #1143, which simply dismissed thread-safety as a requirement for the attribute instance. Given that attributes are cached at multiple levels by different static components, we need to consider where the best place is to handle this synchronization. If we don't solve it on the attribute level, I would argue that Validator
should do its own synchronization (or we should be able to create multiple instances of Validator
for use by multiple threads).