Skip to content

Commit 2cd320e

Browse files
KevinJumpclaude
andauthored
Pre-empt TryConvertTo InvalidCastExceptions (issue #304) (#986)
Two sources of swallowed first-chance InvalidCastExceptions during push, ~100 each per run, that slow the app when a debugger is attached. 1. SyncEntityCache.GetName read from the entity cache (`cache`) while AddName wrote to `nameCache`. The entity cache holds IEntitySlim objects under the same id key, so every name lookup threw IEntitySlim -> CachedName and returned null - the name cache never actually worked. GetName now reads from nameCache. 2. Config/setting values arrive as JsonElement; Umbraco's TryConvertTo throws (and swallows) InvalidCastException turning a JsonElement into a value type e.g. bool. Added JsonTextExtensions.TryConvertPreChecked which does the JsonElement conversion with System.Text.Json first and only falls back to TryConvertTo. Routed the value/config wrappers (ConversionExtensions, SyncValueMapperBase, SyncSerializerOptions, HandlerSettingsExtensions) through it. Adds tests for both fixes. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4afc914 commit 2cd320e

8 files changed

Lines changed: 247 additions & 18 deletions

File tree

uSync.BackOffice/Configuration/uSyncHandlerSettings.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
using Umbraco.Extensions;
66

7+
using uSync.Core.Extensions;
8+
79
namespace uSync.BackOffice.Configuration;
810

911
/// <summary>
@@ -89,10 +91,10 @@ public static class HandlerSettingsExtensions
8991
/// <returns></returns>
9092
public static TResult GetSetting<TResult>(this HandlerSettings settings, string key, TResult defaultValue)
9193
{
92-
if (settings.Settings != null && settings.Settings.TryGetValue(key, out var value))
94+
if (settings.Settings != null && settings.Settings.TryGetValue(key, out var value) && value is not null)
9395
{
94-
var attempt = value.TryConvertTo<TResult>();
95-
if (attempt) return attempt.Result ?? defaultValue;
96+
if (value.TryConvertPreChecked<TResult>(out var result) && result is not null)
97+
return result;
9698
}
9799

98100
return defaultValue;

uSync.Core/Cache/SyncEntityCache.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ public SyncEntityCache(
3939
public CachedName? GetName(int id)
4040
{
4141
if (!_cacheEnabled) return default;
42-
return cache.GetCacheItem<CachedName>(id.ToString());
42+
// read from nameCache - this is where AddName stores CachedName values.
43+
// (reading from `cache` returned IEntitySlim entries under the same key,
44+
// which threw a swallowed InvalidCastException and never actually cached).
45+
return nameCache.GetCacheItem<CachedName>(id.ToString());
4346
}
4447

4548
public void AddName(int id, Guid guid, string name)

uSync.Core/Extensions/ConversionExtensions.cs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
1-
using Umbraco.Extensions;
2-
3-
namespace uSync.Core.Extensions;
1+
namespace uSync.Core.Extensions;
42
internal static class ConversionExtensions
53
{
64
public static TObject? GetValueAs<TObject>(this object value)
75
{
86
if (value == null) return default;
9-
var attempt = value.TryConvertTo<TObject>();
10-
if (!attempt) return default;
11-
return attempt.Result;
7+
return value.TryConvertPreChecked<TObject>(out var result) ? result : default;
128
}
139

1410
public static Guid ConvertToGuid(this int value)

uSync.Core/Extensions/JsonTextExtensions.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,43 @@ private static bool TryGetValueAs<TObject>(this object value, [MaybeNullWhen(fal
400400
return true;
401401
}
402402

403+
/// <summary>
404+
/// Convert a value to the requested type, pre-empting the first-chance
405+
/// InvalidCastException that Umbraco's TryConvertTo throws when converting
406+
/// a JsonElement to a value type (see uSync.Complete issue #304).
407+
/// </summary>
408+
/// <remarks>
409+
/// Settings/config values often arrive as JsonElement (bound from appsettings.json).
410+
/// Asking Umbraco's TryConvertTo to turn one into e.g. a bool throws (and swallows)
411+
/// an InvalidCastException every call - harmless, but noisy and slow when a debugger
412+
/// is attached. Doing the JsonElement conversion with System.Text.Json first means the
413+
/// common path never throws; anything STJ can't handle still falls back to TryConvertTo.
414+
/// </remarks>
415+
public static bool TryConvertPreChecked<TObject>(this object? value, [MaybeNullWhen(false)] out TObject result)
416+
{
417+
result = default;
418+
if (value is null) return false;
419+
420+
if (value is JsonElement element)
421+
{
422+
try
423+
{
424+
result = element.Deserialize<TObject>(_defaultOptions);
425+
if (result is not null) return true;
426+
}
427+
catch
428+
{
429+
// not something STJ could convert directly - fall back to TryConvertTo below.
430+
}
431+
}
432+
433+
var attempt = value.TryConvertTo<TObject>();
434+
if (attempt.Success is false || attempt.Result is null) return false;
435+
436+
result = attempt.Result;
437+
return true;
438+
}
439+
403440
#endregion
404441

405442
#region property getters

uSync.Core/Mapping/SyncValueMapperBase.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,7 @@ protected IEnumerable<uSyncDependency> CreateDependencies(IEnumerable<string> ud
115115
protected static TObject? GetValueAs<TObject>(object value)
116116
{
117117
if (value == null) return default;
118-
var attempt = value.TryConvertTo<TObject>();
119-
if (!attempt) return default;
120-
121-
return attempt.Result;
118+
return value.TryConvertPreChecked<TObject>(out var result) ? result : default;
122119
}
123120
}
124121

uSync.Core/Serialization/SyncSerializerOptions.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
using Umbraco.Extensions;
44

5+
using uSync.Core.Extensions;
6+
57
namespace uSync.Core.Serialization;
68

79
/// <summary>
@@ -70,11 +72,10 @@ public SyncSerializerOptions(SerializerFlags flags, Dictionary<string, object?>
7072

7173
public TResult GetSetting<TResult>(string key, TResult defaultValue)
7274
{
73-
if (this.Settings?.TryGetValue(key, out var value) is true)
75+
if (this.Settings?.TryGetValue(key, out var value) is true && value is not null)
7476
{
75-
var attempt = value.TryConvertTo<TResult>();
76-
if (attempt.Success && attempt.Result is not null)
77-
return attempt.Result;
77+
if (value.TryConvertPreChecked<TResult>(out var result) && result is not null)
78+
return result;
7879
}
7980

8081
return defaultValue;
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
using System;
2+
3+
using Moq;
4+
5+
using NUnit.Framework;
6+
7+
using Umbraco.Cms.Core.Models.Entities;
8+
using Umbraco.Cms.Core.Services;
9+
10+
using uSync.Core.Cache;
11+
12+
namespace uSync.Tests.Cache;
13+
14+
[TestFixture]
15+
internal class SyncEntityCacheTests
16+
{
17+
private Mock<IEntityService> _entityServiceMock;
18+
private Mock<IContentTypeService> _contentTypeServiceMock;
19+
private SyncEntityCache _cache;
20+
21+
[SetUp]
22+
public void Setup()
23+
{
24+
_entityServiceMock = new Mock<IEntityService>();
25+
_contentTypeServiceMock = new Mock<IContentTypeService>();
26+
_cache = new SyncEntityCache(_entityServiceMock.Object, _contentTypeServiceMock.Object);
27+
}
28+
29+
[Test]
30+
public void AddName_ThenGetName_RoundTrips()
31+
{
32+
var id = 1234;
33+
var key = Guid.NewGuid();
34+
35+
_cache.AddName(id, key, "Test Name");
36+
37+
var result = _cache.GetName(id);
38+
39+
Assert.That(result, Is.Not.Null);
40+
Assert.Multiple(() =>
41+
{
42+
Assert.That(result.Key, Is.EqualTo(key));
43+
Assert.That(result.Name, Is.EqualTo("Test Name"));
44+
});
45+
}
46+
47+
// regression for uSync.Complete issue #304 - GetName used to read from the
48+
// entity cache, which holds IEntitySlim objects under the same id key. That
49+
// threw a swallowed InvalidCastException (IEntitySlim -> CachedName) and
50+
// never returned the name. GetName must read from the name cache instead.
51+
[Test]
52+
public void GetName_WhenEntityCachedUnderSameId_StillReturnsName()
53+
{
54+
var id = 4321;
55+
var key = Guid.NewGuid();
56+
57+
var entityMock = new Mock<IEntitySlim>();
58+
entityMock.SetupGet(x => x.Id).Returns(id);
59+
_entityServiceMock.Setup(x => x.Get(id)).Returns(entityMock.Object);
60+
61+
// populate the entity cache for this id (as GetFriendlyPath does).
62+
_ = _cache.GetEntity(id);
63+
64+
_cache.AddName(id, key, "Real Name");
65+
66+
var result = _cache.GetName(id);
67+
68+
Assert.That(result, Is.Not.Null);
69+
Assert.Multiple(() =>
70+
{
71+
Assert.That(result.Key, Is.EqualTo(key));
72+
Assert.That(result.Name, Is.EqualTo("Real Name"));
73+
});
74+
}
75+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
using System;
2+
using System.Text.Json;
3+
4+
using NUnit.Framework;
5+
6+
using uSync.Core.Extensions;
7+
8+
namespace uSync.Tests.Extensions;
9+
10+
/// <summary>
11+
/// tests for the JsonElement pre-check that avoids the swallowed
12+
/// InvalidCastException Umbraco's TryConvertTo throws on JsonElement values
13+
/// (uSync.Complete issue #304).
14+
/// </summary>
15+
[TestFixture]
16+
internal class TryConvertPreCheckedTests
17+
{
18+
[Test]
19+
public void JsonElementTrue_ConvertsToBool()
20+
{
21+
object value = JsonSerializer.SerializeToElement(true);
22+
23+
var success = value.TryConvertPreChecked<bool>(out var result);
24+
25+
Assert.Multiple(() =>
26+
{
27+
Assert.That(success, Is.True);
28+
Assert.That(result, Is.True);
29+
});
30+
}
31+
32+
[Test]
33+
public void JsonElementFalse_ConvertsToBool()
34+
{
35+
object value = JsonSerializer.SerializeToElement(false);
36+
37+
var success = value.TryConvertPreChecked<bool>(out var result);
38+
39+
Assert.Multiple(() =>
40+
{
41+
Assert.That(success, Is.True);
42+
Assert.That(result, Is.False);
43+
});
44+
}
45+
46+
[Test]
47+
public void JsonElementNumber_ConvertsToInt()
48+
{
49+
object value = JsonSerializer.SerializeToElement(42);
50+
51+
var success = value.TryConvertPreChecked<int>(out var result);
52+
53+
Assert.Multiple(() =>
54+
{
55+
Assert.That(success, Is.True);
56+
Assert.That(result, Is.EqualTo(42));
57+
});
58+
}
59+
60+
[Test]
61+
public void JsonElementString_ConvertsToGuid()
62+
{
63+
var guid = Guid.NewGuid();
64+
object value = JsonSerializer.SerializeToElement(guid.ToString());
65+
66+
var success = value.TryConvertPreChecked<Guid>(out var result);
67+
68+
Assert.Multiple(() =>
69+
{
70+
Assert.That(success, Is.True);
71+
Assert.That(result, Is.EqualTo(guid));
72+
});
73+
}
74+
75+
[Test]
76+
public void JsonElementString_ConvertsToString()
77+
{
78+
object value = JsonSerializer.SerializeToElement("hello");
79+
80+
var success = value.TryConvertPreChecked<string>(out var result);
81+
82+
Assert.Multiple(() =>
83+
{
84+
Assert.That(success, Is.True);
85+
Assert.That(result, Is.EqualTo("hello"));
86+
});
87+
}
88+
89+
// a plain CLR value skips the JsonElement branch and still converts via
90+
// the TryConvertTo fallback - behaviour must be unchanged for these.
91+
[Test]
92+
public void PlainString_ConvertsToInt_ViaFallback()
93+
{
94+
object value = "42";
95+
96+
var success = value.TryConvertPreChecked<int>(out var result);
97+
98+
Assert.Multiple(() =>
99+
{
100+
Assert.That(success, Is.True);
101+
Assert.That(result, Is.EqualTo(42));
102+
});
103+
}
104+
105+
[Test]
106+
public void Null_ReturnsFalse()
107+
{
108+
object? value = null;
109+
110+
var success = value.TryConvertPreChecked<bool>(out var result);
111+
112+
Assert.Multiple(() =>
113+
{
114+
Assert.That(success, Is.False);
115+
Assert.That(result, Is.False);
116+
});
117+
}
118+
}

0 commit comments

Comments
 (0)