diff --git a/src/componentsBase/BaseRendererControl.cs b/src/componentsBase/BaseRendererControl.cs index 02d97a9b..b0ea3d1f 100644 --- a/src/componentsBase/BaseRendererControl.cs +++ b/src/componentsBase/BaseRendererControl.cs @@ -155,6 +155,14 @@ internal DataSourceManager DataSourceManager } private LinkedList _messageQueue = new LinkedList(); + /// + /// Guards and the _updateQueued flag that decides when it + /// is flushed. Two paths flush it - SendMessageImmediate on the caller's thread, + /// QueueUpdate on the renderer's - and a component driven from off the Blazor + /// dispatcher (a timer, a bound collection filled in the background) hits both at once. + /// + private readonly object _messageQueueLock = new object(); + private string _containerId = Guid.NewGuid().ToString(); internal string ContainerId @@ -852,7 +860,7 @@ private void SendDescriptionMessage() } RendererMessage m = new RendererMessage(); m.Type = ("description"); - _messageQueue.AddLast(m); + Enqueue(m); QueueUpdate(); } @@ -954,6 +962,13 @@ public string Serialize() private Dictionary _methodReturns = new Dictionary(); private Object _semLock = new Object(); + /// + /// Shared by every instance because the WebView callback paths return only the invoke ID, + /// without a container ID to identify the originating component. + /// + /// + /// Only use as this is incremented from any thread. + /// static long _invokeId = 0; protected async Task InvokeMethod(string methodName, object[] arguments, string[] types, ElementReference[] nativeElements = null) { @@ -995,7 +1010,7 @@ internal object InvokeMethodHelperSync(string target, string methodName, object[ m.Type = ("invokeMethod"); string[] args = new string[arguments.Length]; string[] typeStrings = new string[arguments.Length]; - long invokeId = _invokeId++; + long invokeId = Interlocked.Increment(ref _invokeId); for (int i = 0; i < arguments.Length; i++) { args[i] = GetStringArg(arguments[i], types[i]); @@ -1045,7 +1060,7 @@ internal async Task InvokeMethodHelper(string target, string methodName, m.Type = ("invokeMethod"); string[] args = new string[arguments.Length]; string[] typeStrings = new string[arguments.Length]; - long invokeId = _invokeId++; + long invokeId = Interlocked.Increment(ref _invokeId); for (int i = 0; i < arguments.Length; i++) { args[i] = GetStringArg(arguments[i], types[i]); @@ -1574,7 +1589,7 @@ private void SendMessage(RendererMessage m) return; } //Console.WriteLine("sending message"); - _messageQueue.AddLast(m); + Enqueue(m); QueueUpdate(); } @@ -1585,8 +1600,14 @@ private async Task SendMessageImmediate(RendererMessage m) return null; } - Update(); - return await SendJsonImmediate(m); + // The send must start under this lock. + Task sent; + lock (_messageQueueLock) + { + Update(); + sent = SendJsonImmediate(m); + } + return await sent; } private object SendMessageSyncImmediate(RendererMessage m) @@ -1595,51 +1616,79 @@ private object SendMessageSyncImmediate(RendererMessage m) { return null; } - UpdateSync(); - return SendJsonImmediateSync(m); + lock (_messageQueueLock) + { + UpdateSync(); + return SendJsonImmediateSync(m); + } } - private void QueueUpdate() + private void Enqueue(RendererMessage m) { - if (!_updateQueued && _ready) + lock (_messageQueueLock) { - _updateQueued = true; - Task.Delay(0).ContinueWith((t) => InvokeAsync(Update)); + _messageQueue.AddLast(m); } } - private void Update() + private void QueueUpdate() { - this._updateQueued = false; - - if (!_ready) + bool schedule = false; + lock (_messageQueueLock) { - return; + if (!_updateQueued && _ready) + { + _updateQueued = true; + schedule = true; + } } - //Console.WriteLine("updateing: " + this.GetType().Name + " " + _messageQueue.Count); - while (_messageQueue.Count > 0) + if (schedule) { - RendererMessage m = _messageQueue.First.Value; - _messageQueue.RemoveFirst(); - ProcessMessage(m); + Task.Delay(0).ContinueWith((t) => InvokeAsync(Update)); } } - private void UpdateSync() + private void Update() { - this._updateQueued = false; - - if (!_ready) + // Spans the whole drain, not just the dequeue, so two flushes cannot interleave their + // sends. A thread already holding it can take it again, so nesting is fine. + lock (_messageQueueLock) { - return; + this._updateQueued = false; + + if (!_ready) + { + return; + } + + //Console.WriteLine("updateing: " + this.GetType().Name + " " + _messageQueue.Count); + while (_messageQueue.Count > 0) + { + RendererMessage m = _messageQueue.First.Value; + _messageQueue.RemoveFirst(); + ProcessMessage(m); + } } + } - while (_messageQueue.Count > 0) + private void UpdateSync() + { + lock (_messageQueueLock) { - RendererMessage m = _messageQueue.First.Value; - _messageQueue.RemoveFirst(); - ProcessMessageSync(m); + this._updateQueued = false; + + if (!_ready) + { + return; + } + + while (_messageQueue.Count > 0) + { + RendererMessage m = _messageQueue.First.Value; + _messageQueue.RemoveFirst(); + ProcessMessageSync(m); + } } } @@ -3177,7 +3226,10 @@ private async Task TrySendCleanupAsync() RendererMessage m = new RendererMessage(); m.Type = ("cleanup"); - _messageQueue.Clear(); + lock (_messageQueueLock) + { + _messageQueue.Clear(); + } await SendMessageImmediate(m).ConfigureAwait(false); } catch (JSDisconnectedException ex) diff --git a/tests/IgniteUI.Blazor.Tests/BlazorComponentTestBase.cs b/tests/IgniteUI.Blazor.Tests/BlazorComponentTestBase.cs index 793954be..3c9e2d4b 100644 --- a/tests/IgniteUI.Blazor.Tests/BlazorComponentTestBase.cs +++ b/tests/IgniteUI.Blazor.Tests/BlazorComponentTestBase.cs @@ -19,12 +19,12 @@ public abstract class BlazorComponentTestBase : BunitContext /// The interop harness for components on the default (current) interop stack. protected InteropHarness Interop { get; } - private Dictionary, InteropHarness>? _overrideHarnesses; + private Dictionary? _overrideHarnesses; protected BlazorComponentTestBase() { JSInterop.Mode = JSRuntimeMode.Loose; - Interop = InteropHarnessRegistry.CreateDefault(JSInterop); + Interop = InteropHarnessRegistry.CreateDefault(JSInterop, () => Renderer.Dispatcher); Interop.ConfigureServices(Services); IgniteUIBlazor = Interop.Service; } @@ -50,7 +50,7 @@ protected InteropHarness InteropFor(Type componentType) _overrideHarnesses ??= new(); if (!_overrideHarnesses.TryGetValue(factory, out var harness)) { - harness = factory(JSInterop); + harness = factory(JSInterop, () => Renderer.Dispatcher); harness.ConfigureServices(Services); _overrideHarnesses[factory] = harness; } diff --git a/tests/IgniteUI.Blazor.Tests/ComponentWithContractTestBase.cs b/tests/IgniteUI.Blazor.Tests/ComponentWithContractTestBase.cs index 79a81464..d67365a2 100644 --- a/tests/IgniteUI.Blazor.Tests/ComponentWithContractTestBase.cs +++ b/tests/IgniteUI.Blazor.Tests/ComponentWithContractTestBase.cs @@ -174,7 +174,9 @@ private static async Task RunSpec( ? $"no new current-state read was issued for \"{method.ReadsProperty}\"" : $"\"{method.JsName}\" sent no new invocation"; - var (call, result) = await InvokeExpectingNewCall(matching, () => method.Invoke(cut.Instance), noNewCall); + // On the dispatcher, as application code calling a component API is - see OnDispatcher. + var (call, result) = await InvokeExpectingNewCall( + matching, () => harness.OnDispatcher(() => method.Invoke(cut.Instance)), noNewCall); AssertObserved(harness, cut, scope, method, call, result); if (method.SyncInvoke is not null) @@ -183,7 +185,7 @@ private static async Task RunSpec( // (the stub persists) to the same result. var (syncCall, syncResult) = await InvokeExpectingNewCall( matching, - () => Task.FromResult(method.SyncInvoke(cut.Instance)), + () => Task.FromResult(harness.OnDispatcher(() => method.SyncInvoke(cut.Instance))), "sync twin: " + noNewCall); AssertObserved(harness, cut, scope, method, syncCall, syncResult); } @@ -365,7 +367,7 @@ void Sink(object? value) var registration = harness.FindPropertyUpdate(containerId, WireMemberName(bind.DrivingEvent)) ?? throw new XunitException( $"binding transmitted no \"{bind.DrivingEvent}\" event registration — without it the " + - "client never reports changes, so the binding can never fire"); + $"client never reports changes, so the binding can never fire ({harness.DescribeTraffic(containerId)})"); Assert.Equal(bind.DrivingEvent, registration.GetString()); harness.RaiseEvent(containerId, bind.DrivingEvent, bind.ArgsJson.Get(harness, cut)); @@ -485,7 +487,8 @@ protected void VerifyEventContract() Assert.Equal(bound, evt.Get(cut.Instance)); var wireName = WireMemberName(evt.EventName); var registration = harness.FindPropertyUpdate(containerId, wireName) - ?? throw new XunitException("no event-handler registration transmission was observed"); + ?? throw new XunitException( + "no event-handler registration transmission was observed — " + harness.DescribeTraffic(containerId)); Assert.Equal(evt.EventName, registration.GetString()); var argsJson = evt.ArgsJson.Get(harness, cut); @@ -555,7 +558,7 @@ void Rebind() { throw new XunitException( $"re-binding \"{evt.EventName}\" transmitted {rebound?.ToString() ?? "no registration"} — " + - "the client would never resubscribe"); + $"the client would never resubscribe ({harness.DescribeTraffic(containerId)})"); } } } diff --git a/tests/IgniteUI.Blazor.Tests/Interop/InteropHarness.cs b/tests/IgniteUI.Blazor.Tests/Interop/InteropHarness.cs index 1f4d225c..0e814779 100644 --- a/tests/IgniteUI.Blazor.Tests/Interop/InteropHarness.cs +++ b/tests/IgniteUI.Blazor.Tests/Interop/InteropHarness.cs @@ -98,6 +98,34 @@ private InteropReturn(InteropReturnKind kind, object? value = null, string? type /// public abstract class InteropHarness { + private readonly Func _dispatcher; + + /// + /// Resolves the renderer's dispatcher. Taken as a required argument rather than set afterwards + /// so a harness cannot exist without one, and resolved on use rather than up front because the + /// harness is built while the test's services are still being configured - asking for the + /// renderer that early settles the container. + /// + protected InteropHarness(Func dispatcher) => + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Runs on the renderer's dispatcher, where Blazor delivers real + /// JS-to-.NET calls and application code invokes component APIs. Anything that makes a component + /// transmit belongs here: off the dispatcher the send races the renderer's own flush, and bUnit + /// records both on one unsynchronized list, so a raced message can be lost outright. + /// + public void OnDispatcher(Action work) => + _dispatcher().InvokeAsync(work).GetAwaiter().GetResult(); + + /// + /// + /// Hands back a still-running task instead of awaiting it on the dispatcher, which would hold + /// the dispatcher until it completes and deadlock a deferred return needing it to get there. + /// An interop call transmits before it yields, so the send still happens here. + /// + public T OnDispatcher(Func work) => _dispatcher().InvokeAsync(work).GetAwaiter().GetResult(); + /// The service instance components resolve via DI. public abstract IIgniteUIBlazor Service { get; } @@ -165,6 +193,24 @@ public abstract class InteropHarness /// public abstract void ClearObserved(); + /// + /// The positions of the item insertions transmitted for the instance's bound data, in the order + /// the client received them. Transmission is asynchronous and nothing observable says it has + /// finished, so - what the caller is waiting for - is what the wait + /// is pinned to. Everything transmitted comes back, so both a shortfall and an overshoot are + /// returned to be asserted on rather than hidden. How an insertion is spelled on the wire is + /// implementation-specific; that every one arrives exactly once, in order, is not. + /// + public abstract IReadOnlyList DataItemInsertions(string containerId, int expected); + + /// + /// A short account of what the instance has transmitted, to report alongside an expected + /// transmission that never showed up. Absence on its own is ambiguous: a message that was never + /// queued reads exactly like one that was queued and never flushed, and only the second is a + /// timing problem. "Sent nothing at all" separates them. + /// + public abstract string DescribeTraffic(string containerId); + public IEnumerable CallsOf(string methodName, string? containerId = null) => MethodCalls.Where(c => c.MethodName == methodName && (containerId is null || c.ContainerId == containerId)); diff --git a/tests/IgniteUI.Blazor.Tests/Interop/InteropHarnessRegistry.cs b/tests/IgniteUI.Blazor.Tests/Interop/InteropHarnessRegistry.cs index a4051a8a..604ee313 100644 --- a/tests/IgniteUI.Blazor.Tests/Interop/InteropHarnessRegistry.cs +++ b/tests/IgniteUI.Blazor.Tests/Interop/InteropHarnessRegistry.cs @@ -12,15 +12,18 @@ namespace IgniteUI.Blazor.Tests.Interop; /// public static class InteropHarnessRegistry { - private static readonly Dictionary> Overrides = []; + private static readonly Dictionary Overrides = []; - public static void Register(Func factory) + /// Builds a harness from the test's JS interop and its renderer's dispatcher. + public delegate InteropHarness HarnessFactory(BunitJSInterop jsInterop, Func dispatcher); + + public static void Register(HarnessFactory factory) where TComponent : IComponent => Overrides[typeof(TComponent)] = factory; - public static InteropHarness CreateDefault(BunitJSInterop jsInterop) => - new RendererMessageInteropHarness(jsInterop); + public static InteropHarness CreateDefault(BunitJSInterop jsInterop, Func dispatcher) => + new RendererMessageInteropHarness(jsInterop, dispatcher); - internal static Func? OverrideFor(Type componentType) => + internal static HarnessFactory? OverrideFor(Type componentType) => Overrides.TryGetValue(componentType, out var factory) ? factory : null; } diff --git a/tests/IgniteUI.Blazor.Tests/Interop/RendererMessageInteropHarness.cs b/tests/IgniteUI.Blazor.Tests/Interop/RendererMessageInteropHarness.cs index 23e3806a..9f63bfb7 100644 --- a/tests/IgniteUI.Blazor.Tests/Interop/RendererMessageInteropHarness.cs +++ b/tests/IgniteUI.Blazor.Tests/Interop/RendererMessageInteropHarness.cs @@ -29,8 +29,8 @@ public sealed class RendererMessageInteropHarness : InteropHarness /// Default: forces the JSON data-source channel (as on Blazor Server), keeping data /// transfers observable as refChanged messages. /// - public RendererMessageInteropHarness(BunitJSInterop js) - : this(js, forceJsonDataMarshalling: true) + public RendererMessageInteropHarness(BunitJSInterop js, Func dispatcher) + : this(js, dispatcher, forceJsonDataMarshalling: true) { } @@ -41,7 +41,8 @@ public RendererMessageInteropHarness(BunitJSInterop js) /// DataSourceManager picks UnmarshalledDataSource and the column messages are /// recorded in instead of crossing to JS. /// - public RendererMessageInteropHarness(BunitJSInterop js, bool forceJsonDataMarshalling) + public RendererMessageInteropHarness(BunitJSInterop js, Func dispatcher, bool forceJsonDataMarshalling) + : base(dispatcher) { _js = js; var runtime = forceJsonDataMarshalling @@ -150,7 +151,8 @@ public override void PrimeReady() _js.SetupVoid("igWaitForLoaded", _ => true).SetVoidResult(); } - public override void MakeReady() => _service.WebCallback.OnReady(); + // The JS-to-.NET entries below run on the dispatcher, where Blazor delivers the real ones. + public override void MakeReady() => OnDispatcher(_service.WebCallback.OnReady); public override string ContainerIdOf(IRenderedComponent cut) => cut.Find("[data-ig-id]").GetAttribute("data-ig-id") @@ -232,11 +234,11 @@ public override IEnumerable PropertyReads(string containerId, public override void RaiseEvent(string containerId, string eventName, string argsJson = "{}", string targetName = "mainControl") { var payload = $$"""{"sender": {"refType": "name", "id": "{{targetName}}"}, "args": {{argsJson}}}"""; - _service.WebCallback.OnRaiseEvent(containerId, targetName, eventName, payload); + OnDispatcher(() => _service.WebCallback.OnRaiseEvent(containerId, targetName, eventName, payload)); } public override void CompleteDeferred(InteropMethodCall call, InteropReturn result) => - _service.WebCallback.OnInvokeReturn(call.ContainerId, call.InvokeId, ToResultPayload(result)); + OnDispatcher(() => _service.WebCallback.OnInvokeReturn(call.ContainerId, call.InvokeId, ToResultPayload(result))); public override JsonElement? FindPropertyUpdate(string containerId, string wireName) { @@ -250,8 +252,9 @@ public override void CompleteDeferred(InteropMethodCall call, InteropReturn resu // can starve the queue-flush continuations well past their usual few milliseconds. for (var attempt = 0; attempt < 80; attempt++) { - // One snapshot+parse per attempt, scanned newest-first. - var messages = Messages().Where(m => m.ContainerId == containerId).Reverse().ToList(); + // One snapshot+parse per attempt, newest-first, taken once the instance stops + // transmitting: mid-flush the newest update recorded is not yet the newest one sent. + var messages = SettledMessagesFor(containerId); string? dataRefId = null; foreach (var (_, message, _) in messages) @@ -290,6 +293,108 @@ public override void CompleteDeferred(InteropMethodCall call, InteropReturn resu return null; } + /// + /// This instance's traffic, newest first, taken once it stops transmitting. A flush hands its + /// messages to JS one at a time, so a snapshot can land between two and show an update the next + /// one supersedes - how a script ref read back as the event registration sharing its ref name. + /// Only this instance's traffic can supersede it, so other components never hold it up. + /// + private List<(string ContainerId, JsonElement Message, IReadOnlyList Elements)> SettledMessagesFor(string containerId) + { + // Bounded, so a component that never stops transmitting cannot hang the test; the caller + // has its own budget for concluding absence. + for (var attempt = 0; attempt < 40; attempt++) + { + // Barrier first, and it is what actually settles a flush already sending: it queues + // behind that flush's own dispatcher work item, so the flush has finished by the time + // this returns - however long it was preempted between two sends, which is the part no + // lull can promise. Going first also means the counts below read a record nothing is + // appending to. A flush still waiting on the thread-pool hop that posts it is visible + // to neither, so a caller that knows what it is waiting for should say so instead - see + // DataItemInsertions. + OnDispatcher(() => { }); + var sends = SendCountFor(containerId); + Thread.Sleep(1); + if (SendCountFor(containerId) == sends) + { + break; + } + } + return Messages().Where(m => m.ContainerId == containerId).Reverse().ToList(); + } + + /// On this stack an insertion is a refNotifyInsertItem message carrying its index. + public override IReadOnlyList DataItemInsertions(string containerId, int expected) + { + // Reaching the count asked for is what establishes that transmission happened at all: a + // lull cannot, because a flush still waiting for its thread-pool hop looks exactly like one + // with nothing left to send. Past that point the wait inverts - hold on until the count + // stops moving and the dispatcher is drained - so traffic that *overshoots*, a duplicated + // notification say, is returned to be asserted on rather than cut off at the expected count + // and silently passing. Bounded either way, and answering short is the point: a shortfall is + // the failure worth reporting, not something to hide behind a timeout. Reparsed only when + // the traffic moved, so stopping short costs one parse rather than one per attempt. + var counted = -1; + var steady = 0; + List seen = []; + for (var attempt = 0; attempt < 400; attempt++) + { + var sends = SendCountFor(containerId); + if (sends != counted) + { + counted = sends; + seen = InsertionsFor(containerId); + steady = 0; + } + else if (seen.Count >= expected && ++steady >= 3) + { + // Whatever was already sending has landed by now, so a count still unmoved is done. + OnDispatcher(() => { }); + if (SendCountFor(containerId) == counted) + { + break; + } + steady = 0; + } + Thread.Sleep(1); + } + return seen; + } + + private List InsertionsFor(string containerId) => + [.. Messages() + .Where(m => m.ContainerId == containerId + && m.Message.GetProperty("type").GetString() == "refNotifyInsertItem") + .Select(m => m.Message.GetProperty("index").GetInt32())]; + + public override string DescribeTraffic(string containerId) + { + var messages = SettledMessagesFor(containerId); + if (messages.Count == 0) + { + return "the instance sent nothing at all"; + } + // Oldest first reads better in a failure, and a cap keeps a data-heavy instance from + // burying the message it is attached to. + var kinds = messages.AsEnumerable().Reverse().Take(12).Select(m => m.Message.GetProperty("type").GetString() switch + { + "refChanged" => "refChanged " + Named(m.Message, "refName"), + "invokeMethod" => "invokeMethod " + Named(m.Message, "methodName"), + var type => type ?? "?", + }); + var listed = string.Join(", ", kinds); + return messages.Count > 12 + ? $"it sent {messages.Count} messages: {listed}, ..." + : $"it sent {messages.Count} messages: {listed}"; + } + + private static string Named(JsonElement message, string property) => + message.TryGetProperty(property, out var name) ? name.GetString() ?? "?" : "?"; + + private int SendCountFor(string containerId) => + SnapshotInvocations().Count(i => + i.Identifier == SendMessage && i.Arguments.Count > 0 && i.Arguments[0] as string == containerId); + /// /// refChanged values embed their payload as prefixed strings /// (localJson:::{...}, json:::{...}); unwrap to the actual JSON value. @@ -375,8 +480,10 @@ invocation.Arguments[0] is not string containerId || } /// - /// Components flush queued messages from background continuations, so bUnit's - /// append-only invocation record can grow while we read it. Snapshot with retry. + /// Components flush queued messages from background continuations, so bUnit's append-only + /// invocation record can grow while we read it - and the longer the record, the longer each + /// attempt is exposed, so a component mid-flush can beat several in a row. Yielding is enough + /// once the flush ends; a real pause is what gets us there. /// private IReadOnlyList SnapshotInvocations() { @@ -386,9 +493,16 @@ private IReadOnlyList SnapshotInvocations() { return [.. _js.Invocations]; } - catch (InvalidOperationException) when (attempt < 10) + catch (InvalidOperationException) when (attempt < 200) { - Thread.Yield(); + if (attempt < 10) + { + Thread.Yield(); + } + else + { + Thread.Sleep(1); + } } } } diff --git a/tests/IgniteUI.Blazor.Tests/InteropReadinessTests.cs b/tests/IgniteUI.Blazor.Tests/InteropReadinessTests.cs index 5d3205b2..d8dc7649 100644 --- a/tests/IgniteUI.Blazor.Tests/InteropReadinessTests.cs +++ b/tests/IgniteUI.Blazor.Tests/InteropReadinessTests.cs @@ -15,7 +15,7 @@ public async Task MethodInvocation_BeforeReady_Throws() { var cut = Render(); - await Assert.ThrowsAsync(() => cut.Instance.ShowAsync()); + await Assert.ThrowsAsync(() => Interop.OnDispatcher(cut.Instance.ShowAsync)); Assert.Null(Interop.FindCall("show")); } @@ -26,7 +26,7 @@ public async Task PrimeReady_ReadiesComponent_ThroughNaturalRenderFlow() Interop.SetupMethodResult("show", InteropReturn.Bool(true)); var cut = Render(); - var shown = await cut.Instance.ShowAsync(); + var shown = await Interop.OnDispatcher(cut.Instance.ShowAsync); Assert.True(shown); Assert.NotNull(Interop.FindCall("show", Interop.ContainerIdOf(cut))); @@ -39,7 +39,7 @@ public async Task MakeReady_ReadiesComponents_RenderedBeforeTheLoadedSignal() Interop.SetupMethodResult("hide", InteropReturn.Bool(false)); Interop.MakeReady(); - var hidden = await cut.Instance.HideAsync(); + var hidden = await Interop.OnDispatcher(cut.Instance.HideAsync); Assert.False(hidden); Assert.NotNull(Interop.FindCall("hide", Interop.ContainerIdOf(cut))); diff --git a/tests/IgniteUI.Blazor.Tests/InteropThreadingTests.cs b/tests/IgniteUI.Blazor.Tests/InteropThreadingTests.cs new file mode 100644 index 00000000..8c074ef7 --- /dev/null +++ b/tests/IgniteUI.Blazor.Tests/InteropThreadingTests.cs @@ -0,0 +1,42 @@ +using System.Collections.ObjectModel; +using Bunit; +using IgniteUI.Blazor.Controls; + +namespace IgniteUI.Blazor.Tests; + +/// +/// Thread-safety of the queue a component sends its interop through. A component reaches that +/// queue from wherever it is driven from, and two paths collide without any misuse: change +/// notifications from bound data run on whichever thread mutated it, while the renderer flushes. +/// +public class InteropThreadingTests : BlazorComponentTestBase +{ + private sealed record Row(string Name); + + private const int Rows = 20000; + + [Fact] + public async Task BoundCollection_FilledFromABackgroundTask_DoesNotTearTheMessageQueue() + { + Interop.PrimeReady(); + var data = new ObservableCollection(); + var cut = Render>(ps => ps.Add(c => c.Data, data)); + + // One writer, so the collection itself is never used concurrently: only the component's + // queue sees two threads - these change notifications and the renderer's flush. Filling + // bound data from a background load is ordinary, and it used to throw out of the queue. + await Task.Run(() => + { + for (var i = 0; i < Rows; i++) + { + data.Add(new Row($"row-{i}")); + } + }); + + // Asserted on what the client received, not on the collection we just filled: tearing the + // queue drops and duplicates notifications as readily as it throws, and the producer's own + // count shows neither. Only the renderer drains here, so this says nothing about two + // drains interleaving - that is what holding the lock across the whole drain is for. + Assert.Equal(Enumerable.Range(0, Rows), Interop.DataItemInsertions(Interop.ContainerIdOf(cut), Rows)); + } +} diff --git a/tests/IgniteUI.Blazor.Tests/MethodInteropTests.cs b/tests/IgniteUI.Blazor.Tests/MethodInteropTests.cs index daf34121..d40198cd 100644 --- a/tests/IgniteUI.Blazor.Tests/MethodInteropTests.cs +++ b/tests/IgniteUI.Blazor.Tests/MethodInteropTests.cs @@ -20,7 +20,7 @@ public async Task DeferredReturn_StaysPendingUntilJsDeliversTheResult() Interop.SetupMethodResult("toggle", InteropReturn.Deferred); var cut = Render(); - var pending = cut.Instance.ToggleAsync(); + var pending = Interop.OnDispatcher(cut.Instance.ToggleAsync); Assert.False(pending.IsCompleted); Interop.CompleteDeferred(Interop.RequireCall("toggle"), InteropReturn.Bool(true)); diff --git a/tests/IgniteUI.Blazor.Tests/ScriptPropTests.cs b/tests/IgniteUI.Blazor.Tests/ScriptPropTests.cs index bfc8927f..89bdca43 100644 --- a/tests/IgniteUI.Blazor.Tests/ScriptPropTests.cs +++ b/tests/IgniteUI.Blazor.Tests/ScriptPropTests.cs @@ -80,13 +80,14 @@ public void ScriptProps_TransmitScriptRefs(Type componentType) if (actual is null) { throw new Xunit.Sdk.XunitException( - $"{componentType.Name}.{prop.Name}: no script-ref transmission observed for \"{wireName}\""); + $"{componentType.Name}.{prop.Name}: no script-ref transmission observed for \"{wireName}\" — " + + interop.DescribeTraffic(interop.ContainerIdOf(cut))); } Assert.Equal(scriptName, actual.Value.GetString()); // Clear: interop.ClearObserved(); - prop.SetValue(cut.Instance, null); + interop.OnDispatcher(() => prop.SetValue(cut.Instance, null)); var cleared = interop.FindPropertyUpdate(interop.ContainerIdOf(cut), wireName); if (cleared is null) { diff --git a/tests/IgniteUI.Blazor.Tests/ThreadPoolSetup.cs b/tests/IgniteUI.Blazor.Tests/ThreadPoolSetup.cs new file mode 100644 index 00000000..db182a99 --- /dev/null +++ b/tests/IgniteUI.Blazor.Tests/ThreadPoolSetup.cs @@ -0,0 +1,26 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace IgniteUI.Blazor.Tests; + +internal static class ThreadPoolSetup +{ + /// + /// Components flush their interop queue from a thread-pool work item, while the tests observing + /// that traffic block a thread-pool thread waiting for it - xUnit runs test cases on the pool. + /// With the default floor of one thread per core, the collections running at once can hold every + /// readily available thread, leaving the flush waiting on the pool's slow injection of new ones, + /// and CI compounds it by running one process per target framework at the same time. Raising the + /// floor keeps threads available for the flush, so a wait resolves in milliseconds instead of + /// running out its budget and reporting traffic that was queued but never sent. + /// + [ModuleInitializer] + [SuppressMessage("Usage", "CA2255:The 'ModuleInitializer' attribute should not be used in libraries", + Justification = "Not a library: this is the test assembly configuring its own run, and the " + + "floor has to be raised before the first test takes a pool thread.")] + internal static void RaiseMinimumThreads() + { + ThreadPool.GetMinThreads(out var workers, out var completionPorts); + ThreadPool.SetMinThreads(Math.Max(workers, Environment.ProcessorCount * 8), completionPorts); + } +} diff --git a/tests/IgniteUI.Blazor.Tests/UnmarshalledDataChannelTests.cs b/tests/IgniteUI.Blazor.Tests/UnmarshalledDataChannelTests.cs index f57933ff..58f3865f 100644 --- a/tests/IgniteUI.Blazor.Tests/UnmarshalledDataChannelTests.cs +++ b/tests/IgniteUI.Blazor.Tests/UnmarshalledDataChannelTests.cs @@ -21,7 +21,7 @@ public class UnmarshalledDataChannelTests : BunitContext public UnmarshalledDataChannelTests() { JSInterop.Mode = JSRuntimeMode.Loose; - _interop = new RendererMessageInteropHarness(JSInterop, forceJsonDataMarshalling: false); + _interop = new RendererMessageInteropHarness(JSInterop, () => Renderer.Dispatcher, forceJsonDataMarshalling: false); _interop.ConfigureServices(Services); }