From 4e91d24fdb8d993dfc7b9f515434b8f9017a75a0 Mon Sep 17 00:00:00 2001 From: Loretta Date: Thu, 30 Apr 2026 07:48:01 +0200 Subject: [PATCH] [LOADED_DOCS: 3 files, no new loads] Refactor AcBinaryHubProtocol for thread safety - Removed shared _currentHeaderContext; header context is now passed as a parameter through Parse* and ReadArguments/ReadSingleArgument methods, and stored per-binder for chunked messages. - Updated AyCodeBinaryHubProtocol to use the new header context flow for type resolution and argument deserialization. - Added concurrency tests to verify protocol instance safety under multi-threaded use and prevent state corruption or type resolution races. - Improved documentation and comments to clarify the stateless, concurrency-safe design. --- AyCode.Core.Serializers.Console/Program.cs | 428 ++++++++++++++---- .../docs/BINARY/BINARY_IMPLEMENTATION.md | 2 +- AyCode.Core/docs/BINARY/BINARY_ISSUES.md | 80 ++++ .../AcBinaryHubProtocolConcurrencyTests.cs | 154 +++++++ .../SignalRs/AcBinaryHubProtocol.cs | 99 ++-- .../SignalRs/AyCodeBinaryHubProtocol.cs | 18 +- 6 files changed, 641 insertions(+), 140 deletions(-) create mode 100644 AyCode.Services.Server.Tests/SignalRs/AcBinaryHubProtocolConcurrencyTests.cs diff --git a/AyCode.Core.Serializers.Console/Program.cs b/AyCode.Core.Serializers.Console/Program.cs index 40da481..01364cf 100644 --- a/AyCode.Core.Serializers.Console/Program.cs +++ b/AyCode.Core.Serializers.Console/Program.cs @@ -65,31 +65,70 @@ public static class Program // Set console encoding to UTF-8 for proper Unicode character display System.Console.OutputEncoding = Encoding.UTF8; - var mode = args.Length > 0 ? args[0].ToLower() : "all"; + // Setup validation — abort BEFORE any benchmark logic if MemoryPack baseline is invalid. + // Done early so user is told immediately, not after warmup. + ValidateMemoryPackSetup(); - if (mode == "quick") + // Determine layer (which test data to run) and opMode (ser/des/all). + // CLI args take precedence; if no args, show interactive menu. + string layer; + string opMode = "all"; + + if (args.Length == 0) { - WarmupIterations = 5; - TestIterations = 100; - BenchmarkSamples = 3; - mode = "all"; + var selection = ShowInteractiveMenu(); + if (selection == null) return; // user pressed Q + layer = selection; } - - // Profiler mode: warmup only, then exit (for memory profiler analysis) - if (mode == "profiler") + else { - RunProfilerMode(); - return; + var arg = args[0].ToLower(); + + // Profiler mode: warmup only, then exit (for memory profiler analysis) + if (arg == "profiler") + { + RunProfilerMode(); + return; + } + + // Quick mode: short warmup, few iterations, small sample count + if (arg == "quick") + { + WarmupIterations = 5; + TestIterations = 100; + BenchmarkSamples = 3; + layer = "all"; + } + else if (arg is "core" or "comprehensive" or "edge" or "all") + { + layer = arg; + } + else if (arg is "ser" or "serialize") + { + opMode = "serialize"; + layer = "all"; + } + else if (arg is "des" or "deserialize") + { + opMode = "deserialize"; + layer = "all"; + } + else + { + // Backwards compat: unknown arg → treat as layer keyword + layer = arg; + } } System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗"); System.Console.WriteLine("║ COMPREHENSIVE SERIALIZER BENCHMARK SUITE ║"); System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝"); var allResults = new List(); - var testDataSets = BenchmarkTestDataProvider.CreateTestDataSets(); + var allTestDataSets = BenchmarkTestDataProvider.CreateTestDataSets(); + var testDataSets = FilterByLayer(allTestDataSets, layer); - System.Console.WriteLine($"Mode: {mode} | Iterations: {TestIterations} | Warmup: {WarmupIterations} | Samples: {BenchmarkSamples} (median)"); - System.Console.WriteLine($"Build: {BuildConfiguration} | .NET: {Environment.Version} | Test Type: {testDataSets.FirstOrDefault()?.TypeName ?? "unknown"}"); + System.Console.WriteLine($"Layer: {layer} | OpMode: {opMode} | Iterations: {TestIterations} | Warmup: {WarmupIterations} | Samples: {BenchmarkSamples} (median)"); + System.Console.WriteLine($"Build: {BuildConfiguration} | .NET: {Environment.Version} | Test Type: {testDataSets.FirstOrDefault()?.TypeName ?? "unknown"} | Test Cells: {testDataSets.Count}/{allTestDataSets.Count}"); System.Console.WriteLine(); foreach (var testData in testDataSets) @@ -98,7 +137,7 @@ public static class Program System.Console.WriteLine($"TEST DATA: {testData.DisplayName}"); System.Console.WriteLine($"{'═'.ToString().PadRight(70, '═')}"); - var results = RunBenchmarksForTestData(testData, mode); + var results = RunBenchmarksForTestData(testData, opMode); allResults.AddRange(results); } @@ -172,6 +211,19 @@ public static class Program var results = new List(); var serializers = CreateSerializers(testData); + // Round-trip correctness check — once per (cell × serializer), BEFORE warmup. Aborts the entire benchmark on failure. + System.Console.WriteLine("Verifying round-trip correctness..."); + foreach (var serializer in serializers) + { + if (!serializer.VerifyRoundTrip()) + { + System.Console.Error.WriteLine($"❌ FATAL: Round-trip verification FAILED for {serializer.Name} on {testData.DisplayName}"); + System.Console.Error.WriteLine("Benchmark numbers from a serializer with broken round-trip would be meaningless. Aborting."); + Environment.Exit(1); + } + } + System.Console.WriteLine("✓ All serializers passed round-trip verification."); + // Warmup all serializers System.Console.WriteLine($"Warming up ({WarmupIterations} iterations)..."); foreach (var serializer in serializers) @@ -183,7 +235,7 @@ public static class Program Thread.Sleep(3000); // Run benchmarks - System.Console.WriteLine($"Running benchmarks ({TestIterations} iterations)...\n"); + System.Console.WriteLine($"Running benchmarks ({TestIterations} iterations × {BenchmarkSamples} samples median)...\n"); foreach (var serializer in serializers) { @@ -198,11 +250,14 @@ public static class Program if (mode is "all" or "serialize" or "ser") { result.SerializeTimeMs = RunTimed(() => serializer.Serialize(), TestIterations); + // Dedicated alloc-only sample (separate from timing samples; keeps timing pure) + result.SerializeAllocBytesPerOp = MeasureAllocation(() => serializer.Serialize(), TestIterations); } if (mode is "all" or "deserialize" or "des") { result.DeserializeTimeMs = RunTimed(() => serializer.Deserialize(), TestIterations); + result.DeserializeAllocBytesPerOp = MeasureAllocation(() => serializer.Deserialize(), TestIterations); } results.Add(result); @@ -289,6 +344,115 @@ public static class Program : (times[samples / 2 - 1] + times[samples / 2]) / 2.0; } + /// + /// Measures per-call allocation in bytes after a clean GC. Single dedicated sample (no median) — keeps timing samples pure. + /// + private static long MeasureAllocation(Action action, int iterations) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < iterations; i++) action(); + var after = GC.GetAllocatedBytesForCurrentThread(); + return (after - before) / iterations; + } + + private static readonly JsonSerializerOptions VerifyJsonOpts = new() + { + WriteIndented = false, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles + }; + + /// + /// Round-trip equality check: serialize both via System.Text.Json (canonical form) and compare strings. + /// Slower than property-by-property compare, but universal — works for any object graph without custom comparer. + /// + private static bool DeepEqualsViaJson(object? a, object? b) + { + if (a == null && b == null) return true; + if (a == null || b == null) return false; + var jsonA = JsonSerializer.Serialize(a, VerifyJsonOpts); + var jsonB = JsonSerializer.Serialize(b, VerifyJsonOpts); + return jsonA == jsonB; + } + + /// + /// Validates MemoryPack setup at startup. Aborts the benchmark if TestOrder is not [MemoryPackable]. + /// Without this attribute, MemoryPack falls back to runtime resolver (slower) — comparison would be INVALID. + /// + private static void ValidateMemoryPackSetup() + { + var typesToCheck = new[] { typeof(TestOrder) }; + foreach (var type in typesToCheck) + { + var hasAttr = type.GetCustomAttributes(typeof(MemoryPackableAttribute), inherit: true).Any(); + if (!hasAttr) + { + System.Console.Error.WriteLine($"❌ FATAL: {type.FullName} is not [MemoryPackable] — MemoryPack would fall back to runtime resolver, comparison is INVALID for SGen-vs-SGen claim."); + System.Console.Error.WriteLine("Add [MemoryPackable] to the type and any nested types referenced from it."); + Environment.Exit(1); + } + } + } + + /// + /// Interactive menu shown when no CLI args. Returns the layer keyword (core/comprehensive/edge/all) or null on Quit. + /// + private static string? ShowInteractiveMenu() + { + System.Console.WriteLine(); + System.Console.WriteLine("╔══════════════════════════════════════════════════════════╗"); + System.Console.WriteLine("║ AcBinary Benchmark Suite ║"); + System.Console.WriteLine("╚══════════════════════════════════════════════════════════╝"); + System.Console.WriteLine(); + System.Console.WriteLine("Select benchmark layer:"); + System.Console.WriteLine(); + System.Console.WriteLine(" [1] Core — daily iteration"); + System.Console.WriteLine(" [2] Comprehensive — release validation"); + System.Console.WriteLine(" [3] Edge cases — refactor verification"); + System.Console.WriteLine(" [A] All layers"); + System.Console.WriteLine(" [Q] Quit"); + System.Console.Write("\nSelection: "); + var key = System.Console.ReadKey(intercept: false).KeyChar; + System.Console.WriteLine(); + return char.ToLower(key) switch + { + '1' => "core", + '2' => "comprehensive", + '3' => "edge", + 'a' => "all", + 'q' => null, + _ => "core" + }; + } + + /// + /// Filters test data sets by layer keyword. Layered approach lets you run only what's needed for the iteration cadence. + /// P1: only "Core" data exists (Small/Medium/Large/Repeated/Deep). Comprehensive and Edge layers will be expanded in P2. + /// + private static List FilterByLayer(List all, string layer) + { + if (layer == "all") return all.ToList(); + + var coreNames = new[] { "Small", "Medium", "Large", "Repeated", "Deep" }; + // P2 will add: "Flat", "Polymorphic", "Collection", "Numeric", "NonAscii", etc. + var comprehensiveExtras = new string[] { /* P2 */ }; + // P3 will add: "ColdStart", "VeryLarge", "PathologicalString", etc. + var edgeExtras = new string[] { /* P3 */ }; + + bool StartsWithAny(string name, string[] prefixes) => prefixes.Any(p => name.StartsWith(p)); + + return layer switch + { + "core" => all.Where(t => StartsWithAny(t.Name, coreNames)).ToList(), + "comprehensive" => all.Where(t => StartsWithAny(t.Name, coreNames) || StartsWithAny(t.Name, comprehensiveExtras)).ToList(), + "edge" => all.Where(t => StartsWithAny(t.Name, coreNames) || StartsWithAny(t.Name, comprehensiveExtras) || StartsWithAny(t.Name, edgeExtras)).ToList(), + _ => all.ToList() + }; + } + #endregion #region Serializer Implementations @@ -301,6 +465,8 @@ public static class Program void Warmup(int iterations); void Serialize(); void Deserialize(); + /// Round-trip correctness check — called once per cell before warmup. Returns true if Serialize+Deserialize preserves data. + bool VerifyRoundTrip(); } private sealed class AcBinaryBenchmark : ISerializerBenchmark @@ -346,6 +512,13 @@ public static class Program [MethodImpl(MethodImplOptions.NoInlining)] public void Deserialize() => AcBinaryDeserializer.Deserialize(_serialized, _options); + + public bool VerifyRoundTrip() + { + var bytes = AcBinarySerializer.Serialize(_order, _options); + var roundTripped = AcBinaryDeserializer.Deserialize(bytes, _options); + return DeepEqualsViaJson(_order, roundTripped); + } } private sealed class MemoryPackBenchmark : ISerializerBenchmark @@ -377,6 +550,13 @@ public static class Program [MethodImpl(MethodImplOptions.NoInlining)] public void Deserialize() => MemoryPackSerializer.Deserialize(_serialized); + + public bool VerifyRoundTrip() + { + var bytes = MemoryPackSerializer.Serialize(_order); + var roundTripped = MemoryPackSerializer.Deserialize(bytes); + return DeepEqualsViaJson(_order, roundTripped); + } } private sealed class MessagePackBenchmark : ISerializerBenchmark @@ -418,6 +598,13 @@ public static class Program [MethodImpl(MethodImplOptions.NoInlining)] public void Deserialize() => MessagePackSerializer.Deserialize(_serialized, _options); + + public bool VerifyRoundTrip() + { + var bytes = MessagePackSerializer.Serialize(_order, _options); + var roundTripped = MessagePackSerializer.Deserialize(bytes, _options); + return DeepEqualsViaJson(_order, roundTripped); + } } private sealed class AcBinaryBufferWriterBenchmark : ISerializerBenchmark @@ -459,6 +646,14 @@ public static class Program [MethodImpl(MethodImplOptions.NoInlining)] public void Deserialize() => AcBinaryDeserializer.Deserialize(_serialized, _options); + + public bool VerifyRoundTrip() + { + var bw = new ArrayBufferWriter(); + AcBinarySerializer.Serialize(_order, bw, _options); + var roundTripped = AcBinaryDeserializer.Deserialize(bw.WrittenSpan.ToArray(), _options); + return DeepEqualsViaJson(_order, roundTripped); + } } private sealed class SystemTextJsonBenchmark : ISerializerBenchmark @@ -499,6 +694,13 @@ public static class Program [MethodImpl(MethodImplOptions.NoInlining)] public void Deserialize() => System.Text.Json.JsonSerializer.Deserialize(_serialized, _options); + + public bool VerifyRoundTrip() + { + var json = System.Text.Json.JsonSerializer.Serialize(_order, _options); + var roundTripped = System.Text.Json.JsonSerializer.Deserialize(json, _options); + return DeepEqualsViaJson(_order, roundTripped); + } } #endregion @@ -513,6 +715,8 @@ public static class Program public int SerializedSize { get; set; } public double SerializeTimeMs { get; set; } public double DeserializeTimeMs { get; set; } + public long SerializeAllocBytesPerOp { get; set; } + public long DeserializeAllocBytesPerOp { get; set; } public double RoundTripTimeMs => SerializeTimeMs + DeserializeTimeMs; } @@ -520,7 +724,9 @@ public static class Program { var ser = result.SerializeTimeMs > 0 ? $"{result.SerializeTimeMs,8:F2} ms" : " N/A"; var des = result.DeserializeTimeMs > 0 ? $"{result.DeserializeTimeMs,8:F2} ms" : " N/A"; - System.Console.WriteLine($" {result.SerializerName,-25} | Size: {result.SerializedSize,8:N0} | Ser: {ser} | Des: {des}"); + var serAlloc = result.SerializeTimeMs > 0 ? $"{result.SerializeAllocBytesPerOp,8:N0} B/op" : " N/A"; + var desAlloc = result.DeserializeTimeMs > 0 ? $"{result.DeserializeAllocBytesPerOp,8:N0} B/op" : " N/A"; + System.Console.WriteLine($" {result.SerializerName,-25} | Size: {result.SerializedSize,8:N0} | Ser: {ser} ({serAlloc}) | Des: {des} ({desAlloc})"); } private static void PrintGroupedResults(List results, List testDataSets) @@ -547,7 +753,8 @@ public static class Program foreach (var testData in testDataSets) { var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.RoundTripTimeMs).ToList(); - var msgPackResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerMessagePack); + // Baseline switched MessagePack → MemoryPack: MemoryPack is the SOTA performance leader. + var memPackResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerMemoryPack); var acBinaryResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerAcBinaryDefault); System.Console.WriteLine($"\n┌─ {testData.DisplayName} ─".PadRight(98, '─') + "┐"); @@ -562,24 +769,24 @@ public static class Program var des = result.DeserializeTimeMs > 0 ? $"{result.DeserializeTimeMs:F2} ms" : "N/A"; var rt = result.RoundTripTimeMs > 0 ? $"{result.RoundTripTimeMs:F2} ms" : "N/A"; - // Highlight MessagePack and AcBinary (Default) with win/lose colors - var isHighlighted = result.SerializerName is SerializerMessagePack or SerializerAcBinaryDefault; + // Highlight MemoryPack (baseline) and AcBinary (Default) with win/lose colors + var isHighlighted = result.SerializerName is SerializerMemoryPack or SerializerAcBinaryDefault; var prefix = isHighlighted ? "│►" : "│ "; var suffix = isHighlighted ? "◄│" : " │"; // Color logic: Green = winner (faster), Red = loser (slower) - if (isHighlighted && msgPackResult != null && acBinaryResult != null) + if (isHighlighted && memPackResult != null && acBinaryResult != null) { - var isMsgPack = result.SerializerName == SerializerMessagePack; - var msgPackFaster = msgPackResult.RoundTripTimeMs < acBinaryResult.RoundTripTimeMs; + var isMemPack = result.SerializerName == SerializerMemoryPack; + var memPackFaster = memPackResult.RoundTripTimeMs < acBinaryResult.RoundTripTimeMs; - if (isMsgPack) + if (isMemPack) { - System.Console.ForegroundColor = msgPackFaster ? ConsoleColor.Green : ConsoleColor.Red; + System.Console.ForegroundColor = memPackFaster ? ConsoleColor.Green : ConsoleColor.Red; } else { - System.Console.ForegroundColor = msgPackFaster ? ConsoleColor.Red : ConsoleColor.Green; + System.Console.ForegroundColor = memPackFaster ? ConsoleColor.Red : ConsoleColor.Green; } } @@ -591,16 +798,26 @@ public static class Program } } - // Footer row: AcBinary (Default) vs MessagePack comparison per column - if (msgPackResult != null && acBinaryResult != null) + // Allocation summary row (per-op allocation in bytes; lower is better) + System.Console.WriteLine($"├{"─".PadRight(6, '─')}┼{"─".PadRight(27, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(14, '─')}┼{"─".PadRight(14, '─')}┼{"─".PadRight(14, '─')}┤"); + foreach (var result in testResults) { - var sizePct = (acBinaryResult.SerializedSize / (double)msgPackResult.SerializedSize - 1) * 100; - var serPct = msgPackResult.SerializeTimeMs > 0 ? (acBinaryResult.SerializeTimeMs / msgPackResult.SerializeTimeMs - 1) * 100 : 0; - var desPct = msgPackResult.DeserializeTimeMs > 0 ? (acBinaryResult.DeserializeTimeMs / msgPackResult.DeserializeTimeMs - 1) * 100 : 0; - var rtPct = msgPackResult.RoundTripTimeMs > 0 ? (acBinaryResult.RoundTripTimeMs / msgPackResult.RoundTripTimeMs - 1) * 100 : 0; + if (result.SerializerName is not (SerializerMemoryPack or SerializerAcBinaryDefault)) continue; + var serAlloc = result.SerializeTimeMs > 0 ? $"{result.SerializeAllocBytesPerOp:N0} B/op" : "N/A"; + var desAlloc = result.DeserializeTimeMs > 0 ? $"{result.DeserializeAllocBytesPerOp:N0} B/op" : "N/A"; + System.Console.WriteLine($"│ alloc │ {result.SerializerName,-25} │ {"",10} │ {serAlloc,12} │ {desAlloc,12} │ {"",12} │"); + } + + // Footer row: AcBinary (Default) vs MemoryPack comparison per column + if (memPackResult != null && acBinaryResult != null) + { + var sizePct = (acBinaryResult.SerializedSize / (double)memPackResult.SerializedSize - 1) * 100; + var serPct = memPackResult.SerializeTimeMs > 0 ? (acBinaryResult.SerializeTimeMs / memPackResult.SerializeTimeMs - 1) * 100 : 0; + var desPct = memPackResult.DeserializeTimeMs > 0 ? (acBinaryResult.DeserializeTimeMs / memPackResult.DeserializeTimeMs - 1) * 100 : 0; + var rtPct = memPackResult.RoundTripTimeMs > 0 ? (acBinaryResult.RoundTripTimeMs / memPackResult.RoundTripTimeMs - 1) * 100 : 0; System.Console.WriteLine($"├{"─".PadRight(6, '─')}┴{"─".PadRight(27, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(14, '─')}┼{"─".PadRight(14, '─')}┼{"─".PadRight(13, '─')}┤"); - System.Console.Write($"│ ► Default vs {SerializerMessagePack,-19} │ "); + System.Console.Write($"│ ► Default vs {SerializerMemoryPack,-19} │ "); // Size System.Console.ForegroundColor = sizePct <= 0 ? ConsoleColor.Green : ConsoleColor.Red; @@ -677,61 +894,81 @@ public static class Program if (fastestRt != null) System.Console.WriteLine($"{"Fastest Round-trip",-20} │ {fastestRt.Name,-25} │ {fastestRt.AvgTime,15:F2} ms"); - // Overall AcBinary Default vs MessagePack comparison - var msgPackSerResults = results.Where(r => r.SerializerName == SerializerMessagePack && r.SerializeTimeMs > 0).ToList(); - var msgPackDesResults = results.Where(r => r.SerializerName == SerializerMessagePack && r.DeserializeTimeMs > 0).ToList(); - var msgPackRtResults = results.Where(r => r.SerializerName == SerializerMessagePack && r.RoundTripTimeMs > 0).ToList(); + // Overall AcBinary Default vs MemoryPack comparison (baseline switched MessagePack → MemoryPack as SOTA reference) + var memPackSerResults = results.Where(r => r.SerializerName == SerializerMemoryPack && r.SerializeTimeMs > 0).ToList(); + var memPackDesResults = results.Where(r => r.SerializerName == SerializerMemoryPack && r.DeserializeTimeMs > 0).ToList(); + var memPackRtResults = results.Where(r => r.SerializerName == SerializerMemoryPack && r.RoundTripTimeMs > 0).ToList(); var acBinarySerResults = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.SerializeTimeMs > 0).ToList(); var acBinaryDesResults = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.DeserializeTimeMs > 0).ToList(); var acBinaryRtResults = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.RoundTripTimeMs > 0).ToList(); // Skip comparison if no data available - if (msgPackRtResults.Count == 0 || acBinaryRtResults.Count == 0) + if (memPackRtResults.Count == 0 || acBinaryRtResults.Count == 0) { System.Console.WriteLine(); - System.Console.WriteLine($"── {SerializerAcBinaryDefault} vs {SerializerMessagePack} (Overall) ──"); + System.Console.WriteLine($"── {SerializerAcBinaryDefault} vs {SerializerMemoryPack} (Overall) ──"); System.Console.WriteLine(" (Comparison requires both serialize and deserialize data)"); return; } - var msgPackAvgSer = msgPackSerResults.Count > 0 ? msgPackSerResults.Average(r => r.SerializeTimeMs) : 0; - var msgPackAvgDes = msgPackDesResults.Average(r => r.DeserializeTimeMs); - var msgPackAvgRt = msgPackRtResults.Average(r => r.RoundTripTimeMs); - var msgPackAvgSize = results.Where(r => r.SerializerName == SerializerMessagePack).Average(r => r.SerializedSize); + var memPackAvgSer = memPackSerResults.Count > 0 ? memPackSerResults.Average(r => r.SerializeTimeMs) : 0; + var memPackAvgDes = memPackDesResults.Average(r => r.DeserializeTimeMs); + var memPackAvgRt = memPackRtResults.Average(r => r.RoundTripTimeMs); + var memPackAvgSize = results.Where(r => r.SerializerName == SerializerMemoryPack).Average(r => r.SerializedSize); + var memPackAvgSerAlloc = memPackSerResults.Count > 0 ? memPackSerResults.Average(r => r.SerializeAllocBytesPerOp) : 0; + var memPackAvgDesAlloc = memPackDesResults.Count > 0 ? memPackDesResults.Average(r => r.DeserializeAllocBytesPerOp) : 0; var acBinaryAvgSer = acBinarySerResults.Count > 0 ? acBinarySerResults.Average(r => r.SerializeTimeMs) : 0; var acBinaryAvgDes = acBinaryDesResults.Average(r => r.DeserializeTimeMs); var acBinaryAvgRt = acBinaryRtResults.Average(r => r.RoundTripTimeMs); var acBinaryAvgSize = results.Where(r => r.SerializerName == SerializerAcBinaryDefault).Average(r => r.SerializedSize); + var acBinaryAvgSerAlloc = acBinarySerResults.Count > 0 ? acBinarySerResults.Average(r => r.SerializeAllocBytesPerOp) : 0; + var acBinaryAvgDesAlloc = acBinaryDesResults.Count > 0 ? acBinaryDesResults.Average(r => r.DeserializeAllocBytesPerOp) : 0; System.Console.WriteLine(); - System.Console.WriteLine($"── {SerializerAcBinaryDefault} vs {SerializerMessagePack} (Overall) ──"); + System.Console.WriteLine($"── {SerializerAcBinaryDefault} vs {SerializerMemoryPack} (Overall) ──"); // Only show serialize comparison if data available - if (msgPackAvgSer > 0 && acBinaryAvgSer > 0) + if (memPackAvgSer > 0 && acBinaryAvgSer > 0) { - var serPctAll = (acBinaryAvgSer / msgPackAvgSer - 1) * 100; + var serPctAll = (acBinaryAvgSer / memPackAvgSer - 1) * 100; System.Console.ForegroundColor = serPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red; - System.Console.WriteLine($" Serialize: {serPctAll:+0;-0}% ({acBinaryAvgSer:F2} ms vs {msgPackAvgSer:F2} ms)"); + System.Console.WriteLine($" Serialize: {serPctAll:+0;-0}% ({acBinaryAvgSer:F2} ms vs {memPackAvgSer:F2} ms)"); System.Console.ResetColor(); } - var desPctAll = (acBinaryAvgDes / msgPackAvgDes - 1) * 100; - var rtPctAll = (acBinaryAvgRt / msgPackAvgRt - 1) * 100; - var sizePctAll = (acBinaryAvgSize / msgPackAvgSize - 1) * 100; + var desPctAll = (acBinaryAvgDes / memPackAvgDes - 1) * 100; + var rtPctAll = (acBinaryAvgRt / memPackAvgRt - 1) * 100; + var sizePctAll = (acBinaryAvgSize / memPackAvgSize - 1) * 100; System.Console.ForegroundColor = desPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red; - System.Console.WriteLine($" Deserialize: {desPctAll:+0;-0}% ({acBinaryAvgDes:F2} ms vs {msgPackAvgDes:F2} ms)"); + System.Console.WriteLine($" Deserialize: {desPctAll:+0;-0}% ({acBinaryAvgDes:F2} ms vs {memPackAvgDes:F2} ms)"); System.Console.ResetColor(); System.Console.ForegroundColor = rtPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red; - System.Console.WriteLine($" Round-trip: {rtPctAll:+0;-0}% ({acBinaryAvgRt:F2} ms vs {msgPackAvgRt:F2} ms)"); + System.Console.WriteLine($" Round-trip: {rtPctAll:+0;-0}% ({acBinaryAvgRt:F2} ms vs {memPackAvgRt:F2} ms)"); System.Console.ResetColor(); System.Console.ForegroundColor = sizePctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red; - System.Console.WriteLine($" Size: {sizePctAll:+0;-0}% ({acBinaryAvgSize:F0} B vs {msgPackAvgSize:F0} B)"); + System.Console.WriteLine($" Size: {sizePctAll:+0;-0}% ({acBinaryAvgSize:F0} B vs {memPackAvgSize:F0} B)"); System.Console.ResetColor(); + + // Allocation comparison: byte[] API allocates the output array on both sides — delta shows serializer-overhead diff. + if (memPackAvgSerAlloc > 0 && acBinaryAvgSerAlloc > 0) + { + var serAllocPct = (acBinaryAvgSerAlloc / memPackAvgSerAlloc - 1) * 100; + System.Console.ForegroundColor = serAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red; + System.Console.WriteLine($" Ser Alloc: {serAllocPct:+0;-0}% ({acBinaryAvgSerAlloc:F0} B/op vs {memPackAvgSerAlloc:F0} B/op)"); + System.Console.ResetColor(); + } + if (memPackAvgDesAlloc > 0 && acBinaryAvgDesAlloc > 0) + { + var desAllocPct = (acBinaryAvgDesAlloc / memPackAvgDesAlloc - 1) * 100; + System.Console.ForegroundColor = desAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red; + System.Console.WriteLine($" Des Alloc: {desAllocPct:+0;-0}% ({acBinaryAvgDesAlloc:F0} B/op vs {memPackAvgDesAlloc:F0} B/op)"); + System.Console.ResetColor(); + } } private static void SaveResults(List results, List testDataSets) @@ -791,58 +1028,60 @@ public static class Program sb.AppendLine(); } - // CSV-like data for easy import + // CSV-like data for easy import (now includes per-op allocation columns) sb.AppendLine("=== RAW DATA (CSV) ==="); - sb.AppendLine("TestData,Serializer,Size,SerializeMs,DeserializeMs,RoundTripMs"); + sb.AppendLine("TestData,Serializer,Size,SerializeMs,DeserializeMs,RoundTripMs,SerializeAllocBytesPerOp,DeserializeAllocBytesPerOp"); foreach (var testData in testDataSets) { var testResults = results.Where(r => r.TestDataName == testData.DisplayName).ToList(); foreach (var result in testResults) { - sb.AppendLine($"{result.TestDataName},{result.SerializerName},{result.SerializedSize},{result.SerializeTimeMs:F2},{result.DeserializeTimeMs:F2},{result.RoundTripTimeMs:F2}"); + sb.AppendLine($"{result.TestDataName},{result.SerializerName},{result.SerializedSize},{result.SerializeTimeMs:F2},{result.DeserializeTimeMs:F2},{result.RoundTripTimeMs:F2},{result.SerializeAllocBytesPerOp},{result.DeserializeAllocBytesPerOp}"); } } sb.AppendLine(); // Formatted results sb.AppendLine("=== FORMATTED RESULTS BY TEST DATA ==="); - sb.AppendLine($"(►) = Highlighted: {SerializerMessagePack} (baseline) and {SerializerAcBinaryDefault}"); + sb.AppendLine($"(►) = Highlighted: {SerializerMemoryPack} (baseline) and {SerializerAcBinaryDefault}"); sb.AppendLine(); foreach (var testData in testDataSets) { var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.RoundTripTimeMs).ToList(); - var msgPackResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerMessagePack); + var memPackResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerMemoryPack); var acBinaryResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerAcBinaryDefault); sb.AppendLine(); sb.AppendLine($"--- {testData.DisplayName} ---"); - sb.AppendLine($"{"#",-4} {"Serializer",-26} {"Size",-12} {"Serialize",-14} {"Deserialize",-14} {"Round-trip",-14}"); - sb.AppendLine(new string('-', 86)); + sb.AppendLine($"{"#",-4} {"Serializer",-26} {"Size",-12} {"Serialize",-14} {"Deserialize",-14} {"Round-trip",-14} {"SerAlloc",-12} {"DesAlloc",-12}"); + sb.AppendLine(new string('-', 110)); var rank = 1; foreach (var result in testResults) { - var isHighlighted = result.SerializerName is SerializerMessagePack or SerializerAcBinaryDefault; + var isHighlighted = result.SerializerName is SerializerMemoryPack or SerializerAcBinaryDefault; var prefix = isHighlighted ? "► " : " "; var size = $"{result.SerializedSize:N0}"; var ser = result.SerializeTimeMs > 0 ? $"{result.SerializeTimeMs:F2} ms" : "N/A"; var des = result.DeserializeTimeMs > 0 ? $"{result.DeserializeTimeMs:F2} ms" : "N/A"; var rt = result.RoundTripTimeMs > 0 ? $"{result.RoundTripTimeMs:F2} ms" : "N/A"; + var serAlloc = result.SerializeTimeMs > 0 ? $"{result.SerializeAllocBytesPerOp:N0} B" : "N/A"; + var desAlloc = result.DeserializeTimeMs > 0 ? $"{result.DeserializeAllocBytesPerOp:N0} B" : "N/A"; - sb.AppendLine($"{rank++,2} {prefix}{result.SerializerName,-24} {size,-12} {ser,-14} {des,-14} {rt,-14}"); + sb.AppendLine($"{rank++,2} {prefix}{result.SerializerName,-24} {size,-12} {ser,-14} {des,-14} {rt,-14} {serAlloc,-12} {desAlloc,-12}"); } - // Summary row for this test data - if (msgPackResult != null && acBinaryResult != null) + // Summary row for this test data (vs MemoryPack — baseline switched MessagePack → MemoryPack) + if (memPackResult != null && acBinaryResult != null) { - var sizePct = (acBinaryResult.SerializedSize / (double)msgPackResult.SerializedSize - 1) * 100; - var serPct = msgPackResult.SerializeTimeMs > 0 ? (acBinaryResult.SerializeTimeMs / msgPackResult.SerializeTimeMs - 1) * 100 : 0; - var desPct = msgPackResult.DeserializeTimeMs > 0 ? (acBinaryResult.DeserializeTimeMs / msgPackResult.DeserializeTimeMs - 1) * 100 : 0; - var rtPct = msgPackResult.RoundTripTimeMs > 0 ? (acBinaryResult.RoundTripTimeMs / msgPackResult.RoundTripTimeMs - 1) * 100 : 0; + var sizePct = (acBinaryResult.SerializedSize / (double)memPackResult.SerializedSize - 1) * 100; + var serPct = memPackResult.SerializeTimeMs > 0 ? (acBinaryResult.SerializeTimeMs / memPackResult.SerializeTimeMs - 1) * 100 : 0; + var desPct = memPackResult.DeserializeTimeMs > 0 ? (acBinaryResult.DeserializeTimeMs / memPackResult.DeserializeTimeMs - 1) * 100 : 0; + var rtPct = memPackResult.RoundTripTimeMs > 0 ? (acBinaryResult.RoundTripTimeMs / memPackResult.RoundTripTimeMs - 1) * 100 : 0; - sb.AppendLine($" {SerializerAcBinaryDefault} vs {SerializerMessagePack}: Size {sizePct:+0;-0}% │ Ser {serPct:+0;-0}% │ Des {desPct:+0;-0}% │ RT {rtPct:+0;-0}%"); + sb.AppendLine($" {SerializerAcBinaryDefault} vs {SerializerMemoryPack}: Size {sizePct:+0;-0}% │ Ser {serPct:+0;-0}% │ Des {desPct:+0;-0}% │ RT {rtPct:+0;-0}%"); } //sb.AppendLine($"GrowBufferCount: {AcBinarySerializer.GrowBufferCount}"); @@ -850,42 +1089,50 @@ public static class Program } - // Summary comparison + // Summary comparison (vs MemoryPack) sb.AppendLine(); - sb.AppendLine($"=== {SerializerAcBinaryDefault} vs {SerializerMessagePack} (Overall) ==="); + sb.AppendLine($"=== {SerializerAcBinaryDefault} vs {SerializerMemoryPack} (Overall) ==="); - var msgPackSerResults2 = results.Where(r => r.SerializerName == SerializerMessagePack && r.SerializeTimeMs > 0).ToList(); - var msgPackDesResults2 = results.Where(r => r.SerializerName == SerializerMessagePack && r.DeserializeTimeMs > 0).ToList(); - var msgPackRtResults2 = results.Where(r => r.SerializerName == SerializerMessagePack && r.RoundTripTimeMs > 0).ToList(); + var memPackSerResults2 = results.Where(r => r.SerializerName == SerializerMemoryPack && r.SerializeTimeMs > 0).ToList(); + var memPackDesResults2 = results.Where(r => r.SerializerName == SerializerMemoryPack && r.DeserializeTimeMs > 0).ToList(); + var memPackRtResults2 = results.Where(r => r.SerializerName == SerializerMemoryPack && r.RoundTripTimeMs > 0).ToList(); var acBinarySerResults2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.SerializeTimeMs > 0).ToList(); var acBinaryDesResults2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.DeserializeTimeMs > 0).ToList(); var acBinaryRtResults2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.RoundTripTimeMs > 0).ToList(); - if (msgPackSerResults2.Count > 0 && acBinarySerResults2.Count > 0) + if (memPackSerResults2.Count > 0 && acBinarySerResults2.Count > 0) { - var msgPackAvgSer2 = msgPackSerResults2.Average(r => r.SerializeTimeMs); + var memPackAvgSer2 = memPackSerResults2.Average(r => r.SerializeTimeMs); var acBinaryAvgSer2 = acBinarySerResults2.Average(r => r.SerializeTimeMs); - sb.AppendLine($" Serialize: {((acBinaryAvgSer2 / msgPackAvgSer2 - 1) * 100):+0;-0}% ({acBinaryAvgSer2:F2} ms vs {msgPackAvgSer2:F2} ms)"); + var memPackAvgSerAlloc2 = memPackSerResults2.Average(r => r.SerializeAllocBytesPerOp); + var acBinaryAvgSerAlloc2 = acBinarySerResults2.Average(r => r.SerializeAllocBytesPerOp); + sb.AppendLine($" Serialize: {((acBinaryAvgSer2 / memPackAvgSer2 - 1) * 100):+0;-0}% ({acBinaryAvgSer2:F2} ms vs {memPackAvgSer2:F2} ms)"); + if (memPackAvgSerAlloc2 > 0) + sb.AppendLine($" Ser Alloc: {((acBinaryAvgSerAlloc2 / memPackAvgSerAlloc2 - 1) * 100):+0;-0}% ({acBinaryAvgSerAlloc2:F0} B/op vs {memPackAvgSerAlloc2:F0} B/op)"); } - if (msgPackDesResults2.Count > 0 && acBinaryDesResults2.Count > 0) + if (memPackDesResults2.Count > 0 && acBinaryDesResults2.Count > 0) { - var msgPackAvgDes2 = msgPackDesResults2.Average(r => r.DeserializeTimeMs); + var memPackAvgDes2 = memPackDesResults2.Average(r => r.DeserializeTimeMs); var acBinaryAvgDes2 = acBinaryDesResults2.Average(r => r.DeserializeTimeMs); - sb.AppendLine($" Deserialize: {((acBinaryAvgDes2 / msgPackAvgDes2 - 1) * 100):+0;-0}% ({acBinaryAvgDes2:F2} ms vs {msgPackAvgDes2:F2} ms)"); + var memPackAvgDesAlloc2 = memPackDesResults2.Average(r => r.DeserializeAllocBytesPerOp); + var acBinaryAvgDesAlloc2 = acBinaryDesResults2.Average(r => r.DeserializeAllocBytesPerOp); + sb.AppendLine($" Deserialize: {((acBinaryAvgDes2 / memPackAvgDes2 - 1) * 100):+0;-0}% ({acBinaryAvgDes2:F2} ms vs {memPackAvgDes2:F2} ms)"); + if (memPackAvgDesAlloc2 > 0) + sb.AppendLine($" Des Alloc: {((acBinaryAvgDesAlloc2 / memPackAvgDesAlloc2 - 1) * 100):+0;-0}% ({acBinaryAvgDesAlloc2:F0} B/op vs {memPackAvgDesAlloc2:F0} B/op)"); } - if (msgPackRtResults2.Count > 0 && acBinaryRtResults2.Count > 0) + if (memPackRtResults2.Count > 0 && acBinaryRtResults2.Count > 0) { - var msgPackAvgRt2 = msgPackRtResults2.Average(r => r.RoundTripTimeMs); + var memPackAvgRt2 = memPackRtResults2.Average(r => r.RoundTripTimeMs); var acBinaryAvgRt2 = acBinaryRtResults2.Average(r => r.RoundTripTimeMs); - sb.AppendLine($" Round-trip: {((acBinaryAvgRt2 / msgPackAvgRt2 - 1) * 100):+0;-0}% ({acBinaryAvgRt2:F2} ms vs {msgPackAvgRt2:F2} ms)"); + sb.AppendLine($" Round-trip: {((acBinaryAvgRt2 / memPackAvgRt2 - 1) * 100):+0;-0}% ({acBinaryAvgRt2:F2} ms vs {memPackAvgRt2:F2} ms)"); } - var msgPackAvgSize2 = results.Where(r => r.SerializerName == SerializerMessagePack).Average(r => r.SerializedSize); + var memPackAvgSize2 = results.Where(r => r.SerializerName == SerializerMemoryPack).Average(r => r.SerializedSize); var acBinaryAvgSize2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault).Average(r => r.SerializedSize); - sb.AppendLine($" Size: {((acBinaryAvgSize2 / msgPackAvgSize2 - 1) * 100):+0;-0}% ({acBinaryAvgSize2:F0} B vs {msgPackAvgSize2:F0} B)"); + sb.AppendLine($" Size: {((acBinaryAvgSize2 / memPackAvgSize2 - 1) * 100):+0;-0}% ({acBinaryAvgSize2:F0} B vs {memPackAvgSize2:F0} B)"); File.WriteAllText(logFilePath, sb.ToString(), Utf8NoBom); System.Console.WriteLine($"✓ Results saved to: {logFilePath}"); @@ -901,6 +1148,7 @@ public static class Program var testTypeName = testDataSets.FirstOrDefault()?.TypeName ?? "unknown"; sb.AppendLine($"# AcBinary Benchmark {BuildConfiguration} {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); sb.AppendLine($"Iterations: {TestIterations} | Warmup: {WarmupIterations} | Samples: {BenchmarkSamples} (median) | .NET: {Environment.Version} | TestType: {testTypeName}"); + sb.AppendLine($"Baseline: {SerializerMemoryPack} (SOTA reference) | Verified: round-trip correctness checked once per cell before warmup"); // Options summary var optionsMap = results @@ -917,12 +1165,12 @@ public static class Program sb.AppendLine($"- **{name}**: {opts}"); } - // Flat results table sorted by test data then round-trip + // Flat results table sorted by test data then round-trip (now includes Alloc columns) sb.AppendLine(); sb.AppendLine("## Results"); sb.AppendLine(); - sb.AppendLine("TestData | Serializer | Size(B) | Ser(ms) | Deser(ms) | RT(ms)"); - sb.AppendLine("---|---|---|---|---|---"); + sb.AppendLine("TestData | Serializer | Size(B) | Ser(ms) | Deser(ms) | RT(ms) | SerAlloc(B/op) | DesAlloc(B/op)"); + sb.AppendLine("---|---|---|---|---|---|---|---"); foreach (var testData in testDataSets) { @@ -937,7 +1185,9 @@ public static class Program var ser = r.SerializeTimeMs > 0 ? r.SerializeTimeMs.ToString("F2", inv) : "-"; var des = r.DeserializeTimeMs > 0 ? r.DeserializeTimeMs.ToString("F2", inv) : "-"; var rt = r.RoundTripTimeMs > 0 ? r.RoundTripTimeMs.ToString("F2", inv) : "-"; - sb.AppendLine($"{r.TestDataName} | {r.SerializerName} | {r.SerializedSize} | {ser} | {des} | {rt}"); + var serAlloc = r.SerializeTimeMs > 0 ? r.SerializeAllocBytesPerOp.ToString(inv) : "-"; + var desAlloc = r.DeserializeTimeMs > 0 ? r.DeserializeAllocBytesPerOp.ToString(inv) : "-"; + sb.AppendLine($"{r.TestDataName} | {r.SerializerName} | {r.SerializedSize} | {ser} | {des} | {rt} | {serAlloc} | {desAlloc}"); } } diff --git a/AyCode.Core/docs/BINARY/BINARY_IMPLEMENTATION.md b/AyCode.Core/docs/BINARY/BINARY_IMPLEMENTATION.md index eecd6c7..46d9a05 100644 --- a/AyCode.Core/docs/BINARY/BINARY_IMPLEMENTATION.md +++ b/AyCode.Core/docs/BINARY/BINARY_IMPLEMENTATION.md @@ -117,7 +117,7 @@ Two-phase: **Inlining barriers — `[MethodImpl(AggressiveInlining)]` is silently ignored when:** -- **`try` / `catch` / `finally` / `using`** — any EH region in the method is a hard JIT rule (`inline.cpp` in CoreCLR). `using` statements desugar to `try/finally` and have the same effect. Move resource cleanup (`Pool.Return`, `ArrayPool.Return`, `Dispose`) into a separate cold method or keep the cleanup outside the hot caller. The Pool.Get → try/finally → Pool.Return pattern (Rule #5) is fine because it sits at the entry point of `Serialize`, not on a per-property hot path. +- **`try` / `catch` / `finally` / `using`** — on **.NET 9 (project's minimum target, see `copilot-instructions.md` rule 16)** any EH region is a hard JIT inlining barrier (`inline.cpp` in CoreCLR). `using` statements desugar to `try/finally` and have the same effect. Move resource cleanup (`Pool.Return`, `ArrayPool.Return`, `Dispose`) into a separate cold method or keep the cleanup outside the hot caller. The Pool.Get → try/finally → Pool.Return pattern (Rule #5) is fine because it sits at the entry point of `Serialize`, not on a per-property hot path. — **Treat as a hard rule regardless of runtime.** .NET 10 partially lifts the restriction for same-module try-finally ([`dotnet/runtime#112998`](https://github.com/dotnet/runtime/pull/112998), merged 2025-03-20), but `catch`, cross-module try-finally, and P/Invoke-stub cases stay blocked even there. Code in this repo must run inline-friendly on .NET 9 today AND on .NET 10+ tomorrow — staying EH-free is the portable, conservative choice. Audit scope and rationale: `BINARY_TODO.md#accore-bin-t-t5j8`. - **`stackalloc` with non-constant or large size** — small constant `stackalloc` (≤ ~1KB) is inlinable in .NET 6+, but the moment any other barrier (try/finally, complex control flow) is added the method becomes non-inlinable. When mixing `stackalloc` with `try/finally` (e.g. ArrayPool fallback + scratch buffer), expect the helper to always be a separate call frame — design accordingly (avoid inline-only assumptions in the caller). - **Method size / IL token count** — the JIT has IL-size and basic-block thresholds even with `AggressiveInlining`. For large generated methods (SGen `WriteProperties` for property-heavy types) the attribute is a hint, not a guarantee; see `BINARY_TODO.md#accore-bin-t-t5j8` for `AggressiveOptimization` as a complementary tool. diff --git a/AyCode.Core/docs/BINARY/BINARY_ISSUES.md b/AyCode.Core/docs/BINARY/BINARY_ISSUES.md index 8ce6033..cb4ca49 100644 --- a/AyCode.Core/docs/BINARY/BINARY_ISSUES.md +++ b/AyCode.Core/docs/BINARY/BINARY_ISSUES.md @@ -143,6 +143,86 @@ Assigning a `BufferWriterBinaryOutput` value creates an independent copy. State A single instance must not use context + standalone modes simultaneously — buffer states desynchronize. One mode per lifecycle phase; `FlushAndReset()` as boundary between modes. +## Configuration / Options + +### ACCORE-BIN-I-L8N5: AcBinarySerializerOptions thread-safety — mutable properties on shared instances + +**Status:** Open +**Affects:** `AcBinarySerializerOptions` — all `set;` properties (`UseMetadata`, `UseGeneratedCode`, `WireMode`, `UseStringInterning`, `BufferWriterChunkSize`, `UseCompression`) and any holder that retains a shared instance (e.g. DI-scoped serializer wrapper, long-lived service field, `AcBinaryHubProtocol._options`). + +The options class exposes mutable properties. When a consumer shares one options instance across concurrent `Serialize` / `Deserialize` calls — common with DI-singleton services, hot-path-cached options, or any long-lived holder — a runtime property change is observable mid-operation by other in-flight calls. Result: invariant violations, mismatched encoding decisions, intermittent output / deserialization corruption. + +Worsened by `AcBinaryHubProtocol` ctor (`AyCode.Services/SignalRs/AcBinaryHubProtocol.cs` sor 141), which mutates the caller-provided options reference (`_options.BufferWriterChunkSize = options.BufferSize;`) — the caller's external reference becomes a side-channel for the protocol's internal config. + +A `volatile` field on the holder side (e.g. `_options`) only protects reference replacement, not property-level mutation; an external reference still in scope can be `serOpts.UseGeneratedCode = false;` mid-parse on another thread. + +**Impact:** Latent — corruption is intermittent and timing-dependent; very hard to reproduce without targeted stress. The NuGet contract worsens this: the package cannot constrain how consumers scope their options instances. + +**Possible fix directions:** +- **Defensive copy on ingress** — `Clone()` on `AcBinarySerializerOptions`; every API that retains an options instance clones it on entry. External mutation becomes invisible to the holder. +- **Immutable record refactor** — `set;` → `init;` on all configuration properties; mutation requires `with`-expression which produces a new instance. +- **Read-only flag pattern** — à la `JsonSerializerOptions.MakeReadOnly()`. The holder calls `MakeReadOnly()` on entry; subsequent mutation throws. + +### ACCORE-BIN-I-C5R7: CheckDuplicatePropName=false silently corrupts on FNV-1a hash collision + +**Status:** Open +**Affects:** `AcBinarySerializerOptions.CheckDuplicatePropName` when set to `false` +**Path:** `BINARY_FEATURES.md` sor 104, `BINARY_OPTIONS.md` sor 43 + +The default value (`true`) throws `InvalidOperationException` on FNV-1a property-name hash collision within a type. When set to `false` (the docs explicitly recommend this for production performance), collisions are silently accepted — the second property's hash overwrites the first in the lookup table, and the wrong property setter is invoked during deserialization. Result: **silent data corruption** between the colliding properties. + +The `BINARY_OPTIONS.md` doc gives two contradictory recommendations on the same flag (*"risk of data corruption"* + *"Disable in production for performance"*) without a single decision rule. NuGet consumers reading either passage in isolation can reach opposite conclusions about safety. + +**Impact:** Latent — FNV-1a + typical property names rarely collide, but applications with many SGen types eventually hit one. Detection requires a separate property-by-property comparison after round-trip; the serializer surfaces no signal. + +**Possible fix directions:** +- **Doc harmonization** — single decision rule ("always `true`; perf cost is negligible vs. corruption risk"). +- **Wider hash** — replace FNV-1a with xxHash3-128 (or similar) for collision-free property identification. +- **Disambiguate by index** — store property index alongside hash so a collision is detectable at deserialization without throwing on serialization. + +### ACCORE-BIN-I-P2H8: MaxDepth cut-off Null indistinguishable from real null + +**Status:** Open +**Affects:** `AcBinarySerializerOptions.MaxDepth` (and any preset using a non-default value, e.g. `ShallowCopy` preset has `MaxDepth=0`) +**Path:** `BINARY_OPTIONS.md` sor 67 + +When the object graph exceeds `MaxDepth`, deeper objects/collections are written as `Null(76)` — **the same byte as a genuine null value**. The deserializer cannot distinguish "depth-cut-off null" from "real null" → silent data loss without any signal at the receive side. + +**Impact:** Latent for the default `MaxDepth=255` (real graphs rarely hit). Severe with explicit lower limits — `ShallowCopy` preset (`MaxDepth=0`) silently drops every nested object on the receive side without an exception. Detection requires a separate depth-aware comparison. + +**Possible fix directions:** +- **Dedicated `DepthExceeded` wire marker** (distinct from `Null(76)`) — wire-format breaking change, major version. +- **Configurable policy on cut-off** — `MaxDepthBehavior.WriteNull` (today's default) / `Throw` / `Log+WriteNull`. Non-breaking opt-in. + +### ACCORE-BIN-I-W3F4: PropertyFilter + UseMetadata=false silently corrupts via index drift + +**Status:** Open +**Affects:** `AcBinarySerializerOptions.PropertyFilter` combined with `UseMetadata=false` +**Path:** `BINARY_OPTIONS.md` sor 93 + +When the serializer applies a `PropertyFilter`, excluded properties are completely absent from the stream — no marker, no placeholder. The deserializer must apply an **identical** filter, OR rely on `UseMetadata=true` property-name hash matching. If neither condition holds, positional indices on the receive side mis-match: property A's value lands in property B's setter → silent data corruption. + +**Impact:** Severe in NuGet contexts — the package cannot enforce symmetric filter configuration on both ends. A common pattern ("send-side filter to drop sensitive fields") silently corrupts cross-deployment if the receiver isn't aware to mirror. + +**Possible fix directions:** +- **Emit `PropertySkip(102)` marker** for filtered slots — the marker already exists on the wire, verify the write path uses it for filtered properties. +- **Auto-promote to `UseMetadata=true`** when `PropertyFilter` is set (with a warning) — opt-out via explicit override. +- **Validate at serialize entry** — `PropertyFilter != null && !UseMetadata` → throw `InvalidOperationException` with guidance. + +### ACCORE-BIN-I-J6T9: Non-IId circular references silently truncated when ThrowOnCircularReference=false + +**Status:** Open +**Affects:** `AcBinarySerializerOptions.ThrowOnCircularReference=false` combined with `ReferenceHandling != None` +**Path:** `BINARY_OPTIONS.md` sor 30 + +With `ThrowOnCircularReference=false` + reference handling enabled, **only `IId`-implementing types are tracked for cycle detection**. Non-`IId` circular references hit `MaxDepth` before being detected → silent truncation at the depth boundary, no exception, no log. + +**Impact:** Borderline — explicit opt-in (developer must set `ThrowOnCircularReference=false`), and most domain models avoid non-`IId` cycles. But UI tree models (parent ↔ children with no `Id`), graph data structures, and self-referencing config nodes trigger this path silently. + +**Possible fix directions:** +- **Universal cycle detection** — track all reference types for cycle detection regardless of `IId`-ness when `ThrowOnCircularReference=false` (deduplication remains `IId`-only — cycle detection becomes universal). +- **Diagnostic event** — surface "non-`IId` cycle dropped at depth N" as an `Action?` on options, opt-in. + ## Cross-cutting (canonical home: `../XCUT/`) ### ACCORE-XCUT-I-X8Q1: JSON-in-Binary request parameters — cross-ref diff --git a/AyCode.Services.Server.Tests/SignalRs/AcBinaryHubProtocolConcurrencyTests.cs b/AyCode.Services.Server.Tests/SignalRs/AcBinaryHubProtocolConcurrencyTests.cs new file mode 100644 index 0000000..03ee61e --- /dev/null +++ b/AyCode.Services.Server.Tests/SignalRs/AcBinaryHubProtocolConcurrencyTests.cs @@ -0,0 +1,154 @@ +using System.Buffers; +using AyCode.Services.SignalRs; +using Microsoft.AspNetCore.SignalR.Protocol; + +namespace AyCode.Services.Server.Tests.SignalRs; + +/// +/// Concurrency / thread-safety stress tests for — verify a +/// single shared protocol instance can serve many threads concurrently without per-message state +/// corruption. Regression guard for the per-message header-context race fix (header context is +/// now stack-only on the non-chunked path and per-binder on the chunked path; never on a shared +/// instance field). +/// +/// Mirrors the SignalR DI-singleton scenario: one +/// instance, many threads invoking WriteMessage + TryParseMessage in parallel with +/// distinct payloads. If the prior _currentHeaderContext race were present, threads would +/// see each other's header context (typically a wrong type AQN) → mismatched parse output, type +/// cast exception, or null where a value is expected. +/// +/// The declares the data arg as typeof(object), +/// which forces down the +/// header-context-driven type-resolution path — exactly the path the race used to corrupt. +/// +[TestClass] +public class AcBinaryHubProtocolConcurrencyTests +{ + [TestMethod] + public async Task ConcurrentRoundTrip_SharedProtocolInstance_NoStateCorruption() + { + // Single shared protocol instance — DI-singleton style, the production-realistic NuGet shape. + var protocol = new AyCodeBinaryHubProtocol(); + var binder = new TestInvocationBinder(); + + const int threadCount = 16; + const int iterationsPerThread = 200; + + var tasks = new Task[threadCount]; + for (var t = 0; t < threadCount; t++) + { + var threadIdx = t; + tasks[t] = Task.Run(() => + { + for (var i = 0; i < iterationsPerThread; i++) + { + var dataId = threadIdx * 10000 + i; + + // Distinct payload per (thread, iteration) — race-induced corruption produces + // detectable mismatch in the assertions below. + var msg = new InvocationMessage("OnReceiveMessage", new object?[] + { + dataId, // arg[0]: int (messageTag) + (int?)i, // arg[1]: int? (requestId) + new SignalParams(), // arg[2]: SignalParams + $"data-t{threadIdx}-i{i}" // arg[3]: object → string (header-context-typed) + }); + + var writer = new ArrayBufferWriter(8192); + protocol.WriteMessage(msg, writer); + + var seq = new ReadOnlySequence(writer.WrittenMemory); + var success = protocol.TryParseMessage(ref seq, binder, out var parsed); + + Assert.IsTrue(success, $"thread={threadIdx} iter={i}: TryParseMessage returned false"); + Assert.IsInstanceOfType(parsed, + $"thread={threadIdx} iter={i}: parsed message wrong type"); + + var inv = (InvocationMessage)parsed!; + Assert.AreEqual(4, inv.Arguments.Length, + $"thread={threadIdx} iter={i}: argument count mismatch"); + Assert.AreEqual(dataId, (int)inv.Arguments[0]!, + $"thread={threadIdx} iter={i}: arg[0] (messageTag) mismatch — possible race"); + Assert.AreEqual(i, (int?)inv.Arguments[1], + $"thread={threadIdx} iter={i}: arg[1] (requestId) mismatch"); + Assert.IsInstanceOfType(inv.Arguments[2], + $"thread={threadIdx} iter={i}: arg[2] not SignalParams — header-context race?"); + Assert.AreEqual($"data-t{threadIdx}-i{i}", inv.Arguments[3] as string, + $"thread={threadIdx} iter={i}: arg[3] (data) mismatch — header-context race?"); + } + }); + } + + await Task.WhenAll(tasks); + } + + [TestMethod] + public async Task ConcurrentRoundTrip_VariedPayloadTypes_HeaderContextResolvedPerMessage() + { + // Stresses the header-context Type-resolution path more aggressively: each iteration + // alternates between distinct payload types (string / int / int[]). A header-context + // race would resolve the wrong type AQN → InvalidCastException or silent type-mismatch. + var protocol = new AyCodeBinaryHubProtocol(); + var binder = new TestInvocationBinder(); + + const int threadCount = 8; + const int iterationsPerThread = 150; + + var tasks = new Task[threadCount]; + for (var t = 0; t < threadCount; t++) + { + var threadIdx = t; + tasks[t] = Task.Run(() => + { + for (var i = 0; i < iterationsPerThread; i++) + { + var dataId = threadIdx * 10000 + i; + object data = (i % 3) switch + { + 0 => $"str-t{threadIdx}-i{i}", + 1 => dataId, + _ => new[] { dataId, threadIdx, i } + }; + + var msg = new InvocationMessage("OnReceiveMessage", new object?[] + { + dataId, (int?)i, new SignalParams(), data + }); + + var writer = new ArrayBufferWriter(8192); + protocol.WriteMessage(msg, writer); + + var seq = new ReadOnlySequence(writer.WrittenMemory); + Assert.IsTrue(protocol.TryParseMessage(ref seq, binder, out var parsed), + $"thread={threadIdx} iter={i}: TryParseMessage returned false"); + + var inv = (InvocationMessage)parsed!; + Assert.AreEqual(dataId, (int)inv.Arguments[0]!, + $"thread={threadIdx} iter={i}: messageTag mismatch"); + + // Validate the header-context-driven type was resolved correctly per-message. + switch (i % 3) + { + case 0: + Assert.AreEqual($"str-t{threadIdx}-i{i}", inv.Arguments[3] as string, + $"thread={threadIdx} iter={i}: string payload mismatch — header-context race?"); + break; + case 1: + Assert.AreEqual(dataId, (int)inv.Arguments[3]!, + $"thread={threadIdx} iter={i}: int payload mismatch — header-context race?"); + break; + default: + var arr = inv.Arguments[3] as int[]; + Assert.IsNotNull(arr, + $"thread={threadIdx} iter={i}: int[] payload null — header-context race?"); + Assert.AreEqual(3, arr.Length); + Assert.AreEqual(dataId, arr[0]); + break; + } + } + }); + } + + await Task.WhenAll(tasks); + } +} diff --git a/AyCode.Services/SignalRs/AcBinaryHubProtocol.cs b/AyCode.Services/SignalRs/AcBinaryHubProtocol.cs index 11a76f3..900e5b6 100644 --- a/AyCode.Services/SignalRs/AcBinaryHubProtocol.cs +++ b/AyCode.Services/SignalRs/AcBinaryHubProtocol.cs @@ -93,14 +93,6 @@ public class AcBinaryHubProtocol : IHubProtocol /// private readonly ConditionalWeakTable _chunkStates; - /// - /// Opaque context produced by for the currently-parsed message. - /// Set by parse methods (ParseInvocation, ParseStreamInvocation, ParseStreamItem, ParseCompletion) - /// right after reading the per-message header. Derived protocols can read this to customize - /// argument deserialization (e.g., type resolution when targetType == typeof(object)). - /// - protected object? _currentHeaderContext; - private sealed class AsyncChunkState { public HubMessage PartialMessage = null!; @@ -110,6 +102,14 @@ public class AcBinaryHubProtocol : IHubProtocol public SegmentBufferReader Buffer = null!; public Task? DeserTask; + /// + /// Per-binder header context — the opaque object returned by for + /// the currently chunked message. Persisted across CHUNK_START → CHUNK_DATA × N → CHUNK_END + /// boundaries inside the per-binder entry, so derived classes + /// can consume it during chunked deserialization without sharing state across connections. + /// + public object? HeaderContext; + /// /// Total bytes of chunk frame data already consumed from the input stream /// (including [201][UINT16] framing headers + data bytes). @@ -675,12 +675,16 @@ public class AcBinaryHubProtocol : IHubProtocol r.TryRead(out var msgType); + // The header context (out _) is intentionally discarded on the non-chunked path — + // it lives only on the stack frame of the Parse* call and is consumed inline by the + // ReadArguments / ReadSingleArgument calls inside that frame. No instance state means + // no race even when this protocol instance is shared across threads (NuGet contract). return msgType switch { - MsgInvocation => ParseInvocation(ref r, binder), - MsgStreamInvocation => ParseStreamInvocation(ref r, binder), - MsgStreamItem => ParseStreamItem(ref r, binder), - MsgCompletion => ParseCompletion(ref r, binder), + MsgInvocation => ParseInvocation(ref r, binder, out _), + MsgStreamInvocation => ParseStreamInvocation(ref r, binder, out _), + MsgStreamItem => ParseStreamItem(ref r, binder, out _), + MsgCompletion => ParseCompletion(ref r, binder, out _), MsgCancelInvocation => ParseCancelInvocation(ref r), MsgPing => PingMessage.Instance, MsgClose => ParseClose(ref r), @@ -724,9 +728,9 @@ public class AcBinaryHubProtocol : IHubProtocol target, paramTypes.Count, string.Join(", ", typeNames), remaining); } - private HubMessage ParseInvocation(ref SequenceReader r, IInvocationBinder binder) + private HubMessage ParseInvocation(ref SequenceReader r, IInvocationBinder binder, out object? headerContext) { - _currentHeaderContext = ReadHeader(ref r); + headerContext = ReadHeader(ref r); var invocationId = ReadNullableString(ref r); var target = ReadString(ref r); @@ -734,7 +738,7 @@ public class AcBinaryHubProtocol : IHubProtocol LogParseInvocation(target, paramTypes, r.Remaining); - var args = ReadArguments(ref r, paramTypes); + var args = ReadArguments(ref r, paramTypes, headerContext); var streamIds = ReadStringArray(ref r); var headers = ReadHeaders(ref r); @@ -744,14 +748,14 @@ public class AcBinaryHubProtocol : IHubProtocol return msg; } - private HubMessage ParseStreamInvocation(ref SequenceReader r, IInvocationBinder binder) + private HubMessage ParseStreamInvocation(ref SequenceReader r, IInvocationBinder binder, out object? headerContext) { - _currentHeaderContext = ReadHeader(ref r); + headerContext = ReadHeader(ref r); var invocationId = ReadString(ref r); var target = ReadString(ref r); var paramTypes = binder.GetParameterTypes(target); - var args = ReadArguments(ref r, paramTypes); + var args = ReadArguments(ref r, paramTypes, headerContext); var streamIds = ReadStringArray(ref r); var headers = ReadHeaders(ref r); @@ -761,13 +765,13 @@ public class AcBinaryHubProtocol : IHubProtocol return msg; } - private HubMessage ParseStreamItem(ref SequenceReader r, IInvocationBinder binder) + private HubMessage ParseStreamItem(ref SequenceReader r, IInvocationBinder binder, out object? headerContext) { - _currentHeaderContext = ReadHeader(ref r); + headerContext = ReadHeader(ref r); var invocationId = ReadString(ref r); var itemType = binder.GetStreamItemType(invocationId); - var item = ReadSingleArgument(ref r, itemType); + var item = ReadSingleArgument(ref r, itemType, headerContext); var headers = ReadHeaders(ref r); var msg = new StreamItemMessage(invocationId, item); @@ -776,9 +780,9 @@ public class AcBinaryHubProtocol : IHubProtocol return msg; } - private HubMessage ParseCompletion(ref SequenceReader r, IInvocationBinder binder) + private HubMessage ParseCompletion(ref SequenceReader r, IInvocationBinder binder, out object? headerContext) { - _currentHeaderContext = ReadHeader(ref r); + headerContext = ReadHeader(ref r); var invocationId = ReadString(ref r); var error = ReadNullableString(ref r); @@ -790,7 +794,7 @@ public class AcBinaryHubProtocol : IHubProtocol if (hasResult) { var resultType = binder.GetReturnType(invocationId); - result = ReadSingleArgument(ref r, resultType); + result = ReadSingleArgument(ref r, resultType, headerContext); } var headers = ReadHeaders(ref r); @@ -974,24 +978,29 @@ public class AcBinaryHubProtocol : IHubProtocol _logger?.LogDebug("ParseAsyncChunkStart innerMsgType={InnerMsgType}", originalMsgType); - // Parse the original message normally — -1 marker becomes StreamedArgPlaceholder in ReadArguments - var partialMessage = originalMsgType switch + // Parse the original message normally — -1 marker becomes StreamedArgPlaceholder in ReadArguments. + // The header context returned by Parse* is captured locally and persisted on the per-binder + // AsyncChunkState below, so it survives the CHUNK_START → CHUNK_DATA × N → CHUNK_END boundary + // without any shared instance state (race-mentes on a shared protocol instance). + HubMessage? partialMessage; + object? headerContext; + switch (originalMsgType) { - MsgInvocation => ParseInvocation(ref r, binder), - MsgStreamInvocation => ParseStreamInvocation(ref r, binder), - MsgStreamItem => ParseStreamItem(ref r, binder), - MsgCompletion => ParseCompletion(ref r, binder), - _ => null - }; + case MsgInvocation: partialMessage = ParseInvocation(ref r, binder, out headerContext); break; + case MsgStreamInvocation: partialMessage = ParseStreamInvocation(ref r, binder, out headerContext); break; + case MsgStreamItem: partialMessage = ParseStreamItem(ref r, binder, out headerContext); break; + case MsgCompletion: partialMessage = ParseCompletion(ref r, binder, out headerContext); break; + default: return null; + } if (partialMessage == null) return null; // Find the placeholder arg and its target type var (args, streamedIndex, streamedType) = FindStreamedArgSlot(partialMessage, binder); - // Derived classes can override ResolveStreamedArgType to consult _currentHeaderContext - // (set by ReadHeader) or any other per-message state. - streamedType = ResolveStreamedArgType(streamedType); + // Derived classes can override ResolveStreamedArgType to consult the header context + // (returned by ReadHeader) for per-message type resolution. + streamedType = ResolveStreamedArgType(streamedType, headerContext); _logger?.LogDebug("ParseAsyncChunkStart chunk mode activated streamedIndex={StreamedIndex} streamedType={StreamedType}", streamedIndex, streamedType.Name); @@ -1002,6 +1011,7 @@ public class AcBinaryHubProtocol : IHubProtocol Args = args, StreamedArgIndex = streamedIndex, StreamedArgType = streamedType, + HeaderContext = headerContext, Buffer = new SegmentBufferReader(_options.BufferWriterChunkSize * 2, _logger) // DeserTask started lazily in TryParseChunkData after first chunk is written }; @@ -1157,7 +1167,7 @@ public class AcBinaryHubProtocol : IHubProtocol externalBytes += LengthPrefixSize + argBytes; } - private object?[] ReadArguments(ref SequenceReader r, IReadOnlyList paramTypes) + private object?[] ReadArguments(ref SequenceReader r, IReadOnlyList paramTypes, object? headerContext) { var count = (int)ReadVarUInt(ref r); @@ -1171,7 +1181,7 @@ public class AcBinaryHubProtocol : IHubProtocol LogDiagnostic($"[AcBinaryHubProtocol] arg[{i}] targetType={targetType.Name}; remaining={r.Remaining}"); - args[i] = ReadSingleArgument(ref r, targetType); + args[i] = ReadSingleArgument(ref r, targetType, headerContext); OnArgumentRead(args[i], i); } @@ -1181,17 +1191,22 @@ public class AcBinaryHubProtocol : IHubProtocol protected virtual void OnArgumentRead(object? value, int index) { } /// - /// Override to resolve typeof(object) to a concrete type (e.g., from SignalParams). - /// Called after FindStreamedArgSlot in chunked deserialization. + /// Override to resolve typeof(object) to a concrete type. Called after FindStreamedArgSlot in + /// chunked deserialization with the header context returned by for + /// the same message — derived classes can use it for per-message type resolution without + /// touching shared instance state. /// - protected virtual Type ResolveStreamedArgType(Type binderType) => binderType; + protected virtual Type ResolveStreamedArgType(Type binderType, object? headerContext) => binderType; /// /// Reads a length-prefixed argument and deserializes it from the pipe's backing buffer. /// Zero-copy: SequenceReader slices the pipe's own memory, TryGetArray gives the backing byte[]. - /// SignalDataType enables eager deserialization of response data to the server's actual type. + /// The is the opaque object returned by + /// for the same message — derived classes can use it to drive per-message decoding decisions + /// (e.g. raw-bytes vs typed deserialization, target-type override) without touching shared + /// instance state. /// - protected virtual object? ReadSingleArgument(ref SequenceReader r, Type targetType) + protected virtual object? ReadSingleArgument(ref SequenceReader r, Type targetType, object? headerContext) { r.TryReadLittleEndian(out int argLength); if (argLength == 0) return null; diff --git a/AyCode.Services/SignalRs/AyCodeBinaryHubProtocol.cs b/AyCode.Services/SignalRs/AyCodeBinaryHubProtocol.cs index 5dca757..844897f 100644 --- a/AyCode.Services/SignalRs/AyCodeBinaryHubProtocol.cs +++ b/AyCode.Services/SignalRs/AyCodeBinaryHubProtocol.cs @@ -47,9 +47,11 @@ public class AyCodeBinaryHubProtocol : AcBinaryHubProtocol } /// - /// Opaque context produced by and stashed in - /// . Consumed by - /// and . + /// Opaque context produced by and threaded through Parse* / + /// ReadArguments / ReadSingleArgument as a parameter (or persisted on the per-binder + /// AsyncChunkState for the chunked path). Consumed by + /// and . Stack-only + /// in flight — no shared instance state, race-mentes on a shared protocol instance. /// private sealed class HeaderContext { @@ -148,11 +150,11 @@ public class AyCodeBinaryHubProtocol : AcBinaryHubProtocol /// Prefers the concrete type from the wire header (set by ) when present, /// otherwise falls back to the binder-provided type (base behavior). /// - protected override Type ResolveStreamedArgType(Type binderType) + protected override Type ResolveStreamedArgType(Type binderType, object? headerContext) { - if (_currentHeaderContext is HeaderContext hctx && hctx.Type != null) + if (headerContext is HeaderContext hctx && hctx.Type != null) return hctx.Type; - return base.ResolveStreamedArgType(binderType); + return base.ResolveStreamedArgType(binderType, headerContext); } /// @@ -165,7 +167,7 @@ public class AyCodeBinaryHubProtocol : AcBinaryHubProtocol /// Fall through to base typed deserialization against the binder-provided target type. /// /// - protected override object? ReadSingleArgument(ref SequenceReader r, Type targetType) + protected override object? ReadSingleArgument(ref SequenceReader r, Type targetType, object? headerContext) { r.TryReadLittleEndian(out int argLength); if (argLength == 0) @@ -189,7 +191,7 @@ public class AyCodeBinaryHubProtocol : AcBinaryHubProtocol if (argReader.TryPeek(out byte tag) && tag == BinaryTypeCode.ByteArray) return SequenceToByteArray(argSlice.Slice(1)); - var hctx = _currentHeaderContext as HeaderContext; + var hctx = headerContext as HeaderContext; // 2. Header ConsumerDeserialize: no tag on wire (isAcBinary path on server), // consumer wants raw byte[] — return as-is without deserialization.