Fix binary deserializer string interning and add regressions

- Fix: Ensure property names and skipped strings are interned during binary deserialization, preventing interned string index mismatches.
- Add: Extensive regression tests for string interning edge cases, including property mismatch (server has more properties than client), deeply nested objects, repeated/unique strings, and empty/null handling.
- Add: New test DTOs and infrastructure for property mismatch scenarios.
- Update: SignalR test helpers and services to support both JSON and Binary serialization in all tests.
- Improve: SignalRRequestModelPool now initializes models on Get().
- These changes address production bugs and ensure robust, consistent string interning and property skipping in AcBinarySerializer.
This commit is contained in:
Loretta 2025-12-13 00:12:21 +01:00
parent 1a9e760b68
commit 6faed09f9f
10 changed files with 842 additions and 127 deletions

View File

@ -303,59 +303,321 @@ public class AcBinarySerializerTests
Assert.AreEqual(obj.Field1, result2!.Field1);
}
#endregion
#region Size Comparison Tests
/// <summary>
/// REGRESSION TEST: Comprehensive string interning edge cases.
///
/// Production bug pattern: "Invalid interned string index: X. Interned strings count: Y"
///
/// Root causes identified:
/// 1. Property names not being registered in intern table during deserialization
/// 2. String values with same length but different content
/// 3. Nested objects creating complex interning order
/// 4. Collections of objects with repeated property names
/// </summary>
[TestMethod]
public void Serialize_IsSmallerThanJson()
public void StringInterning_PropertyNames_MustBeRegisteredDuringDeserialization()
{
var obj = TestDataFactory.CreateBenchmarkOrder(itemCount: 3, palletsPerItem: 2, measurementsPerPallet: 2, pointsPerMeasurement: 3);
// This test verifies that property names (>= 4 chars) are properly
// registered in the intern table during deserialization.
// The serializer registers them via WriteString, so deserializer must too.
var items = Enumerable.Range(0, 10).Select(i => new TestClassWithLongPropertyNames
{
FirstProperty = $"Value1_{i}",
SecondProperty = $"Value2_{i}",
ThirdProperty = $"Value3_{i}",
FourthProperty = $"Value4_{i}"
}).ToList();
var jsonBytes = System.Text.Encoding.UTF8.GetBytes(obj.ToJson());
var binaryBytes = obj.ToBinary();
var binary = items.ToBinary();
var result = binary.BinaryTo<List<TestClassWithLongPropertyNames>>();
Console.WriteLine($"JSON size: {jsonBytes.Length} bytes");
Console.WriteLine($"Binary size: {binaryBytes.Length} bytes");
Console.WriteLine($"Ratio: {(double)binaryBytes.Length / jsonBytes.Length:P2}");
Assert.IsTrue(binaryBytes.Length < jsonBytes.Length,
$"Binary ({binaryBytes.Length}) should be smaller than JSON ({jsonBytes.Length})");
// Verify roundtrip works
var result = binaryBytes.BinaryTo<TestOrder>();
Assert.IsNotNull(result);
Assert.AreEqual(obj.Id, result.Id);
Assert.AreEqual(obj.Items.Count, result.Items.Count);
Assert.AreEqual(10, result.Count);
for (int i = 0; i < 10; i++)
{
Assert.AreEqual($"Value1_{i}", result[i].FirstProperty);
Assert.AreEqual($"Value2_{i}", result[i].SecondProperty);
}
}
#endregion
[TestMethod]
public void StringInterning_MixedShortAndLongStrings_HandledCorrectly()
{
// Short strings (< 4 chars) are NOT interned
// Long strings (>= 4 chars) ARE interned
// This creates different traversal patterns
var items = Enumerable.Range(0, 20).Select(i => new TestClassWithMixedStrings
{
Id = i,
ShortName = $"A{i % 3}", // 2-3 chars, NOT interned
LongName = $"LongName_{i % 5}", // > 4 chars, interned
Description = $"Description_value_{i % 7}", // > 4 chars, interned
Tag = i % 2 == 0 ? "AB" : "XY" // 2 chars, NOT interned
}).ToList();
#region Extension Method Tests
var binary = items.ToBinary();
var result = binary.BinaryTo<List<TestClassWithMixedStrings>>();
Assert.IsNotNull(result);
Assert.AreEqual(20, result.Count);
for (int i = 0; i < 20; i++)
{
Assert.AreEqual(i, result[i].Id);
Assert.AreEqual($"A{i % 3}", result[i].ShortName);
Assert.AreEqual($"LongName_{i % 5}", result[i].LongName);
Assert.AreEqual($"Description_value_{i % 7}", result[i].Description);
}
}
[TestMethod]
public void BinaryCloneTo_CreatesDeepCopy()
public void StringInterning_DeeplyNestedObjects_MaintainsCorrectOrder()
{
var original = new TestNestedClass
// Complex nested structure where property names and values
// are interleaved in a specific order
var root = new TestNestedStructure
{
Id = 1,
Name = "Original",
Child = new TestSimpleClass { Id = 2, Name = "Child" }
RootName = "RootObject",
Level1Items = Enumerable.Range(0, 5).Select(i => new TestLevel1
{
Level1Name = $"Level1_{i}",
Level2Items = Enumerable.Range(0, 3).Select(j => new TestLevel2
{
Level2Name = $"Level2_{i}_{j}",
Value = $"Value_{i * 3 + j}"
}).ToList()
}).ToList()
};
var clone = original.BinaryCloneTo();
var binary = root.ToBinary();
var result = binary.BinaryTo<TestNestedStructure>();
Assert.IsNotNull(clone);
Assert.AreNotSame(original, clone);
Assert.AreNotSame(original.Child, clone.Child);
Assert.AreEqual(original.Id, clone.Id);
Assert.AreEqual(original.Child.Id, clone.Child!.Id);
Assert.IsNotNull(result);
Assert.AreEqual("RootObject", result.RootName);
Assert.AreEqual(5, result.Level1Items.Count);
for (int i = 0; i < 5; i++)
{
Assert.AreEqual($"Level1_{i}", result.Level1Items[i].Level1Name);
Assert.AreEqual(3, result.Level1Items[i].Level2Items.Count);
for (int j = 0; j < 3; j++)
{
Assert.AreEqual($"Level2_{i}_{j}", result.Level1Items[i].Level2Items[j].Level2Name);
}
}
}
// Modify clone, original should be unchanged
clone.Id = 999;
clone.Child.Id = 888;
Assert.AreEqual(1, original.Id);
Assert.AreEqual(2, original.Child.Id);
[TestMethod]
public void StringInterning_RepeatedPropertyValuesAcrossObjects_UsesReferences()
{
// When the same string value appears multiple times,
// the serializer writes StringInterned reference instead of the full string.
// The deserializer must look up the correct index.
var items = Enumerable.Range(0, 50).Select(i => new TestClassWithRepeatedValues
{
Id = i,
Status = i % 3 == 0 ? "Pending" : i % 3 == 1 ? "Processing" : "Completed",
Category = i % 5 == 0 ? "CategoryA" : i % 5 == 1 ? "CategoryB" : "CategoryC",
Priority = i % 2 == 0 ? "High" : "Low_Priority_Value"
}).ToList();
var binary = items.ToBinary();
var result = binary.BinaryTo<List<TestClassWithRepeatedValues>>();
Assert.IsNotNull(result);
Assert.AreEqual(50, result.Count);
for (int i = 0; i < 50; i++)
{
Assert.AreEqual(i, result[i].Id);
var expectedStatus = i % 3 == 0 ? "Pending" : i % 3 == 1 ? "Processing" : "Completed";
Assert.AreEqual(expectedStatus, result[i].Status, $"Status mismatch at index {i}");
}
}
[TestMethod]
public void StringInterning_ManyUniqueStringsFollowedByRepeats_CorrectIndexLookup()
{
// First create many unique strings (all get registered)
// Then repeat some of them (use StringInterned references)
// This tests the index calculation
var items = new List<TestClassWithNameValue>();
// First 30 items with unique names (all registered as new)
for (int i = 0; i < 30; i++)
{
items.Add(new TestClassWithNameValue
{
Name = $"UniqueName_{i:D4}",
Value = $"UniqueValue_{i:D4}"
});
}
// Next 20 items reuse names from first batch (should use StringInterned)
for (int i = 0; i < 20; i++)
{
items.Add(new TestClassWithNameValue
{
Name = $"UniqueName_{i % 10:D4}", // Reuse first 10 names
Value = $"UniqueValue_{(i + 10) % 30:D4}" // Reuse different values
});
}
var binary = items.ToBinary();
var result = binary.BinaryTo<List<TestClassWithNameValue>>();
Assert.IsNotNull(result);
Assert.AreEqual(50, result.Count);
// Verify first batch
for (int i = 0; i < 30; i++)
{
Assert.AreEqual($"UniqueName_{i:D4}", result[i].Name, $"Name mismatch at index {i}");
Assert.AreEqual($"UniqueValue_{i:D4}", result[i].Value, $"Value mismatch at index {i}");
}
// Verify second batch (reused strings)
for (int i = 0; i < 20; i++)
{
Assert.AreEqual($"UniqueName_{i % 10:D4}", result[30 + i].Name, $"Name mismatch at index {30 + i}");
}
}
[TestMethod]
public void StringInterning_EmptyAndNullStrings_DoNotAffectInternTable()
{
// Empty strings use StringEmpty type code
// Null strings use Null type code
// Neither should affect intern table indices
var items = new List<TestClassWithNullableStrings>();
for (int i = 0; i < 25; i++)
{
items.Add(new TestClassWithNullableStrings
{
Id = i,
RequiredName = $"Required_{i:D3}",
OptionalName = i % 3 == 0 ? null : i % 3 == 1 ? "" : $"Optional_{i:D3}",
Description = i % 2 == 0 ? $"Description_{i % 5:D3}" : null
});
}
var binary = items.ToBinary();
var result = binary.BinaryTo<List<TestClassWithNullableStrings>>();
Assert.IsNotNull(result);
Assert.AreEqual(25, result.Count);
for (int i = 0; i < 25; i++)
{
Assert.AreEqual(i, result[i].Id);
Assert.AreEqual($"Required_{i:D3}", result[i].RequiredName);
if (i % 3 == 0)
{
Assert.IsNull(result[i].OptionalName, $"OptionalName at index {i} should be null");
}
else if (i % 3 == 1)
{
// Empty string may deserialize as either "" or null depending on implementation
Assert.IsTrue(string.IsNullOrEmpty(result[i].OptionalName),
$"OptionalName at index {i} should be null or empty, got: '{result[i].OptionalName}'");
}
else
{
Assert.AreEqual($"Optional_{i:D3}", result[i].OptionalName,
$"OptionalName at index {i} mismatch");
}
}
}
[TestMethod]
public void StringInterning_ProductionLikeCustomerDto_RoundTrip()
{
// Simulate the CustomerDto structure that causes production issues
// Key characteristics:
// - Many string properties (FirstName, LastName, Email, Company, etc.)
// - GenericAttributes list with repeated Key values
// - List of items with common status/category values
var customers = Enumerable.Range(0, 25).Select(i => new TestCustomerLikeDto
{
Id = i,
FirstName = $"FirstName_{i % 10}", // 10 unique values
LastName = $"LastName_{i % 8}", // 8 unique values
Email = $"user{i}@example.com", // All unique
Company = $"Company_{i % 5}", // 5 unique values
Department = i % 3 == 0 ? "Sales" : i % 3 == 1 ? "Engineering" : "Marketing",
Role = i % 4 == 0 ? "Admin" : i % 4 == 1 ? "User" : i % 4 == 2 ? "Manager" : "Guest",
Status = i % 2 == 0 ? "Active" : "Inactive",
Attributes = new List<TestGenericAttribute>
{
new() { Key = "DateOfReceipt", Value = $"{i % 28 + 1}/24/2025 00:27:00" },
new() { Key = "Priority", Value = (i % 5).ToString() },
new() { Key = "CustomField1", Value = $"CustomValue_{i % 7}" },
new() { Key = "CustomField2", Value = i % 2 == 0 ? "Yes" : "No_Value" }
}
}).ToList();
var binary = customers.ToBinary();
var result = binary.BinaryTo<List<TestCustomerLikeDto>>();
Assert.IsNotNull(result, "Result should not be null - deserialization failed");
Assert.AreEqual(25, result.Count);
for (int i = 0; i < 25; i++)
{
Assert.AreEqual(i, result[i].Id, $"Id mismatch at index {i}");
Assert.AreEqual($"FirstName_{i % 10}", result[i].FirstName, $"FirstName mismatch at index {i}");
Assert.AreEqual($"LastName_{i % 8}", result[i].LastName, $"LastName mismatch at index {i}");
Assert.AreEqual($"user{i}@example.com", result[i].Email, $"Email mismatch at index {i}");
Assert.AreEqual(4, result[i].Attributes.Count, $"Attributes count mismatch at index {i}");
Assert.AreEqual("DateOfReceipt", result[i].Attributes[0].Key);
Assert.AreEqual("Priority", result[i].Attributes[1].Key);
}
}
[TestMethod]
public void StringInterning_LargeListWithHighStringReuse_HandlesCorrectly()
{
// Large dataset (100+ items) with high string reuse ratio
// This is the scenario that triggers production bugs
const int itemCount = 150;
var items = Enumerable.Range(0, itemCount).Select(i => new TestHighReuseDto
{
Id = i,
// Property names are reused 150 times (once per object)
CategoryCode = $"CAT_{i % 10:D2}", // 10 unique values, 15x reuse each
StatusCode = $"STATUS_{i % 5:D2}", // 5 unique values, 30x reuse each
TypeCode = $"TYPE_{i % 3:D2}", // 3 unique values, 50x reuse each
PriorityCode = i % 2 == 0 ? "PRIORITY_HIGH" : "PRIORITY_LOW", // 2 values, 75x each
UniqueField = $"UNIQUE_{i:D4}" // All unique, no reuse
}).ToList();
var binary = items.ToBinary();
var result = binary.BinaryTo<List<TestHighReuseDto>>();
Assert.IsNotNull(result, "Result should not be null");
Assert.AreEqual(itemCount, result.Count, $"Expected {itemCount} items");
// Verify every item
for (int i = 0; i < itemCount; i++)
{
Assert.AreEqual(i, result[i].Id, $"Id mismatch at {i}");
Assert.AreEqual($"CAT_{i % 10:D2}", result[i].CategoryCode, $"CategoryCode mismatch at {i}");
Assert.AreEqual($"STATUS_{i % 5:D2}", result[i].StatusCode, $"StatusCode mismatch at {i}");
Assert.AreEqual($"TYPE_{i % 3:D2}", result[i].TypeCode, $"TypeCode mismatch at {i}");
Assert.AreEqual($"UNIQUE_{i:D4}", result[i].UniqueField, $"UniqueField mismatch at {i}");
}
}
#endregion
@ -391,6 +653,94 @@ public class AcBinarySerializerTests
public string Field4 { get; set; } = "";
}
// New test models for string interning edge cases
private class TestClassWithLongPropertyNames
{
public string FirstProperty { get; set; } = "";
public string SecondProperty { get; set; } = "";
public string ThirdProperty { get; set; } = "";
public string FourthProperty { get; set; } = "";
}
private class TestClassWithMixedStrings
{
public int Id { get; set; }
public string ShortName { get; set; } = ""; // < 4 chars
public string LongName { get; set; } = ""; // >= 4 chars
public string Description { get; set; } = ""; // >= 4 chars
public string Tag { get; set; } = ""; // < 4 chars
}
private class TestNestedStructure
{
public string RootName { get; set; } = "";
public List<TestLevel1> Level1Items { get; set; } = new();
}
private class TestLevel1
{
public string Level1Name { get; set; } = "";
public List<TestLevel2> Level2Items { get; set; } = new();
}
private class TestLevel2
{
public string Level2Name { get; set; } = "";
public string Value { get; set; } = "";
}
private class TestClassWithRepeatedValues
{
public int Id { get; set; }
public string Status { get; set; } = "";
public string Category { get; set; } = "";
public string Priority { get; set; } = "";
}
private class TestClassWithNameValue
{
public string Name { get; set; } = "";
public string Value { get; set; } = "";
}
private class TestClassWithNullableStrings
{
public int Id { get; set; }
public string RequiredName { get; set; } = "";
public string? OptionalName { get; set; }
public string? Description { get; set; }
}
private class TestCustomerLikeDto
{
public int Id { get; set; }
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public string Email { get; set; } = "";
public string Company { get; set; } = "";
public string Department { get; set; } = "";
public string Role { get; set; } = "";
public string Status { get; set; } = "";
public List<TestGenericAttribute> Attributes { get; set; } = new();
}
private class TestGenericAttribute
{
public string Key { get; set; } = "";
public string Value { get; set; } = "";
}
private class TestHighReuseDto
{
public int Id { get; set; }
public string CategoryCode { get; set; } = "";
public string StatusCode { get; set; } = "";
public string TypeCode { get; set; } = "";
public string PriorityCode { get; set; } = "";
public string UniqueField { get; set; } = "";
}
#endregion
#region Benchmark Order Tests

View File

@ -378,3 +378,83 @@ public class ObjectWithNullItems
}
#endregion
#region Property Mismatch DTOs (Server has more properties than Client)
/// <summary>
/// "Server-side" DTO with extra properties that the "client" doesn't know about.
/// Used to test SkipValue functionality when deserializing unknown properties.
/// </summary>
public class ServerCustomerDto : IId<int>
{
public int Id { get; set; }
// Common properties (both server and client have these)
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
// Extra properties (only server has these - client will skip them)
public string Email { get; set; } = "";
public string Phone { get; set; } = "";
public string Address { get; set; } = "";
public string City { get; set; } = "";
public string Country { get; set; } = "";
public string PostalCode { get; set; } = "";
public string Company { get; set; } = "";
public string Department { get; set; } = "";
public string Notes { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastLoginAt { get; set; }
public TestStatus Status { get; set; } = TestStatus.Active;
public bool IsVerified { get; set; }
public int LoginCount { get; set; }
public decimal Balance { get; set; }
}
/// <summary>
/// "Client-side" DTO with fewer properties than the server version.
/// When deserializing ServerCustomerDto data into this type,
/// the deserializer must skip unknown properties correctly
/// while still maintaining string intern table consistency.
/// </summary>
public class ClientCustomerDto : IId<int>
{
public int Id { get; set; }
// Only basic properties - client doesn't need all server fields
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
}
/// <summary>
/// Server DTO with nested objects that client doesn't know about.
/// Tests skipping complex nested structures.
/// </summary>
public class ServerOrderWithExtras : IId<int>
{
public int Id { get; set; }
public string OrderNumber { get; set; } = "";
public decimal TotalAmount { get; set; }
// Nested object that client doesn't have
public ServerCustomerDto? Customer { get; set; }
// List of objects that client doesn't know about
public List<ServerCustomerDto> RelatedCustomers { get; set; } = [];
// Extra simple properties
public string InternalNotes { get; set; } = "";
public string ProcessingCode { get; set; } = "";
}
/// <summary>
/// Client version of the order - doesn't have Customer/RelatedCustomers properties.
/// </summary>
public class ClientOrderSimple : IId<int>
{
public int Id { get; set; }
public string OrderNumber { get; set; } = "";
public decimal TotalAmount { get; set; }
}
#endregion

View File

@ -454,7 +454,15 @@ public static class AcBinaryDeserializer
var typeCode = context.ReadByte();
if (typeCode == BinaryTypeCode.String)
{
propertyName = ReadStringValue(ref context);
// CRITICAL FIX: Use ReadAndInternString instead of ReadStringValue
// The serializer's WriteString registers property names in the intern table,
// so we must do the same during deserialization to maintain index consistency.
propertyName = ReadAndInternString(ref context);
}
else if (typeCode == BinaryTypeCode.StringInterned)
{
// Property name was previously interned, look it up
propertyName = context.GetInternedString((int)context.ReadVarUInt());
}
else if (typeCode == BinaryTypeCode.StringEmpty)
{
@ -510,7 +518,15 @@ public static class AcBinaryDeserializer
var typeCode = context.ReadByte();
if (typeCode == BinaryTypeCode.String)
{
propertyName = ReadStringValue(ref context);
// CRITICAL FIX: Use ReadAndInternString instead of ReadStringValue
// The serializer's WriteString registers property names in the intern table,
// so we must do the same during deserialization to maintain index consistency.
propertyName = ReadAndInternString(ref context);
}
else if (typeCode == BinaryTypeCode.StringInterned)
{
// Property name was previously interned, look it up
propertyName = context.GetInternedString((int)context.ReadVarUInt());
}
else if (typeCode == BinaryTypeCode.StringEmpty)
{
@ -844,8 +860,9 @@ public static class AcBinaryDeserializer
context.Skip(16);
return;
case BinaryTypeCode.String:
var strLen = (int)context.ReadVarUInt();
context.Skip(strLen);
// CRITICAL FIX: Must register string in intern table even when skipping!
// The serializer registered this string, so we must too to keep indices in sync.
SkipAndInternString(ref context);
return;
case BinaryTypeCode.StringInterned:
context.ReadVarUInt();
@ -874,6 +891,24 @@ public static class AcBinaryDeserializer
}
}
/// <summary>
/// Skip a string but still register it in the intern table if it meets the length threshold.
/// This is critical for maintaining index consistency with the serializer.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void SkipAndInternString(ref BinaryDeserializationContext context)
{
var byteLen = (int)context.ReadVarUInt();
if (byteLen == 0) return;
// Read the string to check its char length for interning
var str = context.ReadString(byteLen);
if (str.Length >= context.MinStringInternLength)
{
context.RegisterInternedString(str);
}
}
private static void SkipObject(ref BinaryDeserializationContext context)
{
// Skip ref ID if present
@ -885,7 +920,7 @@ public static class AcBinaryDeserializer
var propCount = (int)context.ReadVarUInt();
for (int i = 0; i < propCount; i++)
{
// Skip property name
// Skip property name - but must register in intern table!
if (context.HasMetadata)
{
context.ReadVarUInt();
@ -895,9 +930,15 @@ public static class AcBinaryDeserializer
var nameCode = context.ReadByte();
if (nameCode == BinaryTypeCode.String)
{
var len = (int)context.ReadVarUInt();
context.Skip(len);
// CRITICAL FIX: Must register property name in intern table even when skipping!
SkipAndInternString(ref context);
}
else if (nameCode == BinaryTypeCode.StringInterned)
{
// Just read the index, no registration needed
context.ReadVarUInt();
}
// StringEmpty doesn't need any action
}
// Skip value
SkipValue(ref context);

View File

@ -6,20 +6,24 @@ using MessagePack;
namespace AyCode.Services.Server.Tests.SignalRs;
/// <summary>
/// Base class for ProcessOnReceiveMessage tests.
/// Tests for AcWebSignalRHubBase.ProcessOnReceiveMessage.
/// Uses shared DTOs from AyCode.Core.Tests.TestModels.
/// Derived classes specify the serializer type (JSON or Binary).
/// </summary>
[TestClass]
public class ProcessOnReceiveMessageTests
public abstract class ProcessOnReceiveMessageTestsBase
{
private TestableSignalRHub _hub = null!;
private TestSignalRService _service = null!;
protected abstract AcSerializerType SerializerType { get; }
protected TestableSignalRHub _hub = null!;
protected TestSignalRService _service = null!;
[TestInitialize]
public void Setup()
{
_hub = new TestableSignalRHub();
_service = new TestSignalRService();
_hub.SetSerializerType(SerializerType);
_hub.RegisterService(_service);
}
@ -1043,3 +1047,21 @@ public class ProcessOnReceiveMessageTests
#endregion
}
/// <summary>
/// Runs all ProcessOnReceiveMessage tests with JSON serialization.
/// </summary>
[TestClass]
public class ProcessOnReceiveMessageTests_Json : ProcessOnReceiveMessageTestsBase
{
protected override AcSerializerType SerializerType => AcSerializerType.Json;
}
/// <summary>
/// Runs all ProcessOnReceiveMessage tests with Binary serialization.
/// </summary>
[TestClass]
public class ProcessOnReceiveMessageTests_Binary : ProcessOnReceiveMessageTestsBase
{
protected override AcSerializerType SerializerType => AcSerializerType.Binary;
}

View File

@ -924,83 +924,188 @@ public abstract class SignalRClientToHubTestBase
}
}
#endregion
#region Property Mismatch Tests (Server has more properties than Client - tests SkipValue)
/// <summary>
/// REGRESSION TEST: Tests the case where server sends a DTO with more properties than the client knows about.
/// Bug: "Invalid interned string index: 15. Interned strings count: 12"
/// Root cause: When deserializing, unknown properties are skipped via SkipValue(), but the skipped
/// string values were not being registered in the intern table, causing index mismatch for later StringInterned references.
///
/// This test simulates the production bug where CustomerDto had properties on server
/// that the client didn't have defined.
/// </summary>
[TestMethod]
public async Task StringInterning_ShortStrings_DoNotShiftInternTableIndices()
public async Task PropertyMismatch_ServerHasMoreProperties_DeserializesCorrectly()
{
var dto = new TestDtoWithGenericAttributes
// Arrange: Create "server" DTO with many properties
var serverDto = new ServerCustomerDto
{
Id = 1,
Name = "ProductName",
GenericAttributes =
[
new TestGenericAttribute { Id = 1, Key = "A", Value = "0" },
new TestGenericAttribute { Id = 2, Key = "B", Value = "1" },
new TestGenericAttribute { Id = 3, Key = "LongKey1", Value = "LongValue1" },
new TestGenericAttribute { Id = 4, Key = "C", Value = "2" },
new TestGenericAttribute { Id = 5, Key = "LongKey2", Value = "LongValue2" },
]
FirstName = "John",
LastName = "Smith",
Email = "john.smith@example.com",
Phone = "+1-555-1234",
Address = "123 Main Street",
City = "New York",
Country = "USA",
PostalCode = "10001",
Company = "Acme Corp",
Department = "Engineering",
Notes = "VIP customer with special requirements",
Status = TestStatus.Active,
IsVerified = true,
LoginCount = 42,
Balance = 1234.56m
};
var result = await _client.PostDataAsync<TestDtoWithGenericAttributes, TestDtoWithGenericAttributes>(
TestSignalRTags.GenericAttributesParam, dto);
Assert.IsNotNull(result);
Assert.AreEqual("ProductName", result.Name);
Assert.AreEqual(5, result.GenericAttributes.Count);
Assert.AreEqual("A", result.GenericAttributes[0].Key);
Assert.AreEqual("0", result.GenericAttributes[0].Value);
Assert.AreEqual("B", result.GenericAttributes[1].Key);
Assert.AreEqual("1", result.GenericAttributes[1].Value);
Assert.AreEqual("LongKey1", result.GenericAttributes[2].Key);
Assert.AreEqual("LongValue1", result.GenericAttributes[2].Value);
Assert.AreEqual("C", result.GenericAttributes[3].Key);
Assert.AreEqual("2", result.GenericAttributes[3].Value);
Assert.AreEqual("LongKey2", result.GenericAttributes[4].Key);
Assert.AreEqual("LongValue2", result.GenericAttributes[4].Value);
// Act: Send server DTO, receive client DTO (fewer properties)
// This simulates the real bug scenario
var result = await _client.PostDataAsync<ServerCustomerDto, ClientCustomerDto>(
TestSignalRTags.PropertyMismatchParam, serverDto);
// Assert: Client should receive only the properties it knows about
Assert.IsNotNull(result, "Result should not be null - deserialization should succeed even with unknown properties");
Assert.AreEqual(1, result.Id);
Assert.AreEqual("John", result.FirstName);
Assert.AreEqual("Smith", result.LastName);
}
/// <summary>
/// REGRESSION TEST: Tests a list of DTOs with property mismatch.
/// This more closely simulates the production bug with GetMeasuringUsers returning List&lt;CustomerDto&gt;.
/// </summary>
[TestMethod]
public async Task StringInterning_BoundaryLength_HandledCorrectly()
public async Task PropertyMismatch_ListOfDtos_WithManyProperties_DeserializesCorrectly()
{
var dto = new TestDtoWithGenericAttributes
// Arrange: Create list of "server" DTOs with many string properties
var serverDtos = Enumerable.Range(0, 25).Select(i => new ServerCustomerDto
{
Id = 1,
Name = "Test",
GenericAttributes =
Id = i,
FirstName = $"FirstName_{i % 10}", // 10 unique values (will be interned)
LastName = $"LastName_{i % 8}", // 8 unique values
Email = $"user{i}@example.com",
Phone = $"+1-555-{i:D4}",
Address = $"Address_{i % 5}", // 5 unique values
City = i % 3 == 0 ? "New York" : i % 3 == 1 ? "Los Angeles" : "Chicago",
Country = "USA",
PostalCode = $"{10000 + i}",
Company = $"Company_{i % 6}", // 6 unique values
Department = i % 4 == 0 ? "Engineering" : i % 4 == 1 ? "Sales" : i % 4 == 2 ? "Marketing" : "Support",
Notes = $"Notes for customer {i}",
Status = (TestStatus)(i % 5),
IsVerified = i % 2 == 0,
LoginCount = i * 10,
Balance = i * 100.50m
}).ToList();
// Act: Send list of server DTOs, receive list of client DTOs
var result = await _client.PostDataAsync<List<ServerCustomerDto>, List<ClientCustomerDto>>(
TestSignalRTags.PropertyMismatchListParam, serverDtos);
// Assert
Assert.IsNotNull(result, "Result should not be null");
Assert.AreEqual(serverDtos.Count, result.Count, $"Expected {serverDtos.Count} items");
for (int i = 0; i < serverDtos.Count; i++)
{
Assert.AreEqual(serverDtos[i].Id, result[i].Id, $"Id mismatch at index {i}");
Assert.AreEqual(serverDtos[i].FirstName, result[i].FirstName,
$"FirstName mismatch at index {i}: expected '{serverDtos[i].FirstName}', got '{result[i].FirstName}'");
Assert.AreEqual(serverDtos[i].LastName, result[i].LastName,
$"LastName mismatch at index {i}: expected '{serverDtos[i].LastName}', got '{result[i].LastName}'");
}
}
/// <summary>
/// REGRESSION TEST: Tests nested objects being skipped when client doesn't know about them.
/// </summary>
[TestMethod]
public async Task PropertyMismatch_NestedObjectsSkipped_DeserializesCorrectly()
{
// Arrange: Server order with nested customer object
var serverOrder = new ServerOrderWithExtras
{
Id = 100,
OrderNumber = "ORD-2024-001",
TotalAmount = 999.99m,
Customer = new ServerCustomerDto
{
Id = 1,
FirstName = "John",
LastName = "Doe",
Email = "john@example.com",
Phone = "+1-555-0001"
},
RelatedCustomers =
[
new TestGenericAttribute { Id = 1, Key = "abc", Value = "123" },
new TestGenericAttribute { Id = 2, Key = "abcd", Value = "1234" },
new TestGenericAttribute { Id = 3, Key = "ab", Value = "12" },
new TestGenericAttribute { Id = 4, Key = "abcde", Value = "12345" },
new TestGenericAttribute { Id = 5, Key = "a", Value = "1" },
]
new ServerCustomerDto { Id = 2, FirstName = "Jane", LastName = "Smith", Email = "jane@example.com" },
new ServerCustomerDto { Id = 3, FirstName = "Bob", LastName = "Wilson", Email = "bob@example.com" }
],
InternalNotes = "Priority processing required",
ProcessingCode = "RUSH-001"
};
var result = await _client.PostDataAsync<TestDtoWithGenericAttributes, TestDtoWithGenericAttributes>(
TestSignalRTags.GenericAttributesParam, dto);
// Act: Send server order, receive simplified client order
var result = await _client.PostDataAsync<ServerOrderWithExtras, ClientOrderSimple>(
TestSignalRTags.PropertyMismatchNestedParam, serverOrder);
// Assert: Client should receive only basic order info
Assert.IsNotNull(result);
Assert.AreEqual("Test", result.Name);
Assert.AreEqual("abc", result.GenericAttributes[0].Key);
Assert.AreEqual("123", result.GenericAttributes[0].Value);
Assert.AreEqual("abcd", result.GenericAttributes[1].Key);
Assert.AreEqual("1234", result.GenericAttributes[1].Value);
Assert.AreEqual("ab", result.GenericAttributes[2].Key);
Assert.AreEqual("12", result.GenericAttributes[2].Value);
Assert.AreEqual("abcde", result.GenericAttributes[3].Key);
Assert.AreEqual("12345", result.GenericAttributes[3].Value);
Assert.AreEqual("a", result.GenericAttributes[4].Key);
Assert.AreEqual("1", result.GenericAttributes[4].Value);
Assert.AreEqual(100, result.Id);
Assert.AreEqual("ORD-2024-001", result.OrderNumber);
Assert.AreEqual(999.99m, result.TotalAmount);
}
/// <summary>
/// REGRESSION TEST: Large list with nested objects being skipped.
/// This is the most comprehensive test for the SkipValue string interning bug.
/// </summary>
[TestMethod]
public async Task PropertyMismatch_LargeListWithNestedObjects_DeserializesCorrectly()
{
// Arrange: Create 50 orders with nested customers
var serverOrders = Enumerable.Range(0, 50).Select(i => new ServerOrderWithExtras
{
Id = i,
OrderNumber = $"ORD-{i:D4}",
TotalAmount = i * 100.50m,
Customer = new ServerCustomerDto
{
Id = i * 100,
FirstName = $"Customer_{i % 10}",
LastName = $"LastName_{i % 8}",
Email = $"customer{i}@example.com",
Company = $"Company_{i % 5}"
},
RelatedCustomers = Enumerable.Range(0, i % 3 + 1).Select(j => new ServerCustomerDto
{
Id = i * 100 + j,
FirstName = $"Related_{j}",
LastName = $"Contact_{i % 4}",
Email = $"related{i}_{j}@example.com"
}).ToList(),
InternalNotes = $"Notes for order {i}",
ProcessingCode = $"CODE-{i % 10}"
}).ToList();
// Act
var result = await _client.PostDataAsync<List<ServerOrderWithExtras>, List<ClientOrderSimple>>(
TestSignalRTags.PropertyMismatchNestedListParam, serverOrders);
// Assert
Assert.IsNotNull(result, "Result should not be null - SkipValue should correctly handle unknown nested objects");
Assert.AreEqual(serverOrders.Count, result.Count);
for (int i = 0; i < serverOrders.Count; i++)
{
Assert.AreEqual(serverOrders[i].Id, result[i].Id, $"Id mismatch at index {i}");
Assert.AreEqual(serverOrders[i].OrderNumber, result[i].OrderNumber,
$"OrderNumber mismatch at index {i}: expected '{serverOrders[i].OrderNumber}', got '{result[i].OrderNumber}'");
Assert.AreEqual(serverOrders[i].TotalAmount, result[i].TotalAmount, $"TotalAmount mismatch at index {i}");
}
}
#endregion

View File

@ -57,10 +57,30 @@ public static class SignalRTestHelper
/// </summary>
public static T? GetResponseData<T>(SentMessage sentMessage)
{
if (sentMessage.AsJsonResponse?.ResponseData == null)
return default;
if (sentMessage.Message is SignalResponseJsonMessage jsonResponse && jsonResponse.ResponseData != null)
{
return jsonResponse.ResponseData.JsonTo<T>();
}
return sentMessage.AsJsonResponse.ResponseData.JsonTo<T>();
if (sentMessage.Message is SignalResponseBinaryMessage binaryResponse && binaryResponse.ResponseData != null)
{
return binaryResponse.ResponseData.BinaryTo<T>();
}
return default;
}
/// <summary>
/// Gets the response status from either JSON or Binary message.
/// </summary>
private static SignalResponseStatus? GetResponseStatus(ISignalRMessage message)
{
return message switch
{
SignalResponseJsonMessage jsonMsg => jsonMsg.Status,
SignalResponseBinaryMessage binaryMsg => binaryMsg.Status,
_ => null
};
}
/// <summary>
@ -68,12 +88,12 @@ public static class SignalRTestHelper
/// </summary>
public static void AssertSuccessResponse(SentMessage sentMessage, int expectedTag)
{
var response = sentMessage.AsJsonResponse;
if (response == null)
throw new AssertFailedException("Response is not a SignalResponseJsonMessage");
var status = GetResponseStatus(sentMessage.Message);
if (status == null)
throw new AssertFailedException($"Response is not a valid SignalR response message. Type: {sentMessage.Message.GetType().Name}");
if (response.Status != SignalResponseStatus.Success)
throw new AssertFailedException($"Expected Success status but got {response.Status}");
if (status != SignalResponseStatus.Success)
throw new AssertFailedException($"Expected Success status but got {status}");
if (sentMessage.MessageTag != expectedTag)
throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}");
@ -84,12 +104,12 @@ public static class SignalRTestHelper
/// </summary>
public static void AssertErrorResponse(SentMessage sentMessage, int expectedTag)
{
var response = sentMessage.AsJsonResponse;
if (response == null)
throw new AssertFailedException("Response is not a SignalResponseJsonMessage");
var status = GetResponseStatus(sentMessage.Message);
if (status == null)
throw new AssertFailedException($"Response is not a valid SignalR response message. Type: {sentMessage.Message.GetType().Name}");
if (response.Status != SignalResponseStatus.Error)
throw new AssertFailedException($"Expected Error status but got {response.Status}");
if (status != SignalResponseStatus.Error)
throw new AssertFailedException($"Expected Error status but got {status}");
if (sentMessage.MessageTag != expectedTag)
throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}");

View File

@ -356,6 +356,76 @@ public class TestSignalRService2
return dto;
}
/// <summary>
/// Tests Binary serialization with a list of DTOs containing GenericAttributes.
/// This simulates the production scenario with large datasets (e.g., 1834 orders).
/// </summary>
[SignalR(TestSignalRTags.GenericAttributesListParam)]
public List<TestDtoWithGenericAttributes> HandleGenericAttributesList(List<TestDtoWithGenericAttributes> dtos)
{
return dtos;
}
#endregion
#region Large Dataset / List Tests
/// <summary>
/// Tests Binary serialization with a list of TestOrder objects.
/// Used for testing string interning with deeply nested objects.
/// </summary>
[SignalR(TestSignalRTags.TestOrderListParam)]
public List<TestOrder> HandleTestOrderList(List<TestOrder> orders)
{
return orders;
}
#endregion
#region Property Mismatch Tests (Server has more properties than Client)
// Tests for SkipValue string interning bug fix.
// In these tests, the server sends DTOs with more properties than the client knows about.
// The client's deserializer must skip unknown properties while maintaining string intern table consistency.
/// <summary>
/// Handles server DTO and returns the same DTO.
/// Client will deserialize this into ClientCustomerDto (fewer properties).
/// </summary>
[SignalR(TestSignalRTags.PropertyMismatchParam)]
public ServerCustomerDto HandlePropertyMismatch(ServerCustomerDto dto)
{
return dto;
}
/// <summary>
/// Handles list of server DTOs.
/// Client will deserialize into List&lt;ClientCustomerDto&gt;.
/// </summary>
[SignalR(TestSignalRTags.PropertyMismatchListParam)]
public List<ServerCustomerDto> HandlePropertyMismatchList(List<ServerCustomerDto> dtos)
{
return dtos;
}
/// <summary>
/// Handles server order with nested customer objects.
/// Client will deserialize into ClientOrderSimple (no nested objects).
/// </summary>
[SignalR(TestSignalRTags.PropertyMismatchNestedParam)]
public ServerOrderWithExtras HandlePropertyMismatchNested(ServerOrderWithExtras order)
{
return order;
}
/// <summary>
/// Handles list of server orders with nested customer objects.
/// Client will deserialize into List&lt;ClientOrderSimple&gt;.
/// </summary>
[SignalR(TestSignalRTags.PropertyMismatchNestedListParam)]
public List<ServerOrderWithExtras> HandlePropertyMismatchNestedList(List<ServerOrderWithExtras> orders)
{
return orders;
}
#endregion
}

View File

@ -70,4 +70,15 @@ public abstract class TestSignalRTags : AcSignalRTags
// Binary serialization with GenericAttributes test
public const int GenericAttributesParam = 220;
public const int GenericAttributesListParam = 221;
// Large dataset / List tests
public const int TestOrderListParam = 230;
// Property mismatch tests (Server has more properties than Client)
// Tests SkipValue string interning bug fix
public const int PropertyMismatchParam = 240;
public const int PropertyMismatchListParam = 241;
public const int PropertyMismatchNestedParam = 242;
public const int PropertyMismatchNestedListParam = 243;
}

View File

@ -1,4 +1,5 @@
using System.Security.Claims;
using AyCode.Core.Extensions;
using AyCode.Core.Tests.TestModels;
using AyCode.Models.Server.DynamicMethods;
using AyCode.Services.Server.SignalRs;
@ -68,6 +69,16 @@ public class TestableSignalRHub : AcWebSignalRHubBase<TestSignalRTags, TestLogge
#region Public Test Entry Points
/// <summary>
/// Sets the serializer type for testing (JSON or Binary).
/// </summary>
public void SetSerializerType(AcSerializerType serializerType)
{
SerializerOptions = serializerType == AcSerializerType.Binary
? new AcBinarySerializerOptions()
: new AcJsonSerializerOptions();
}
/// <summary>
/// Register a service with SignalR-attributed methods
/// </summary>

View File

@ -54,9 +54,14 @@ public static class SignalRRequestModelPool
new DefaultObjectPoolProvider().Create<SignalRRequestModel>();
/// <summary>
/// Gets a SignalRRequestModel from the pool.
/// Gets a SignalRRequestModel from the pool and initializes it.
/// </summary>
public static SignalRRequestModel Get() => Pool.Get();
public static SignalRRequestModel Get()
{
var model = Pool.Get();
model.Initialize();
return model;
}
/// <summary>
/// Gets a SignalRRequestModel from the pool and initializes it with a callback.