diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9fec097 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,52 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +DSoft.MessageBus is a cross-platform EventBus/messaging library (like `NSNotificationCenter` on iOS, `otto` on Android) that decouples application components. Shipped as two NuGet packages: `DSoft.MessageBus` (main) and `DSoft.MessageBus.Core` (contracts/core types). + +## Build & Test + +Solution file is `MessageBus.slnx` (new XML solution format — requires recent .NET 10 SDK / VS). + +```bash +dotnet restore MessageBus.slnx +dotnet build MessageBus.slnx --configuration Release +dotnet test UnitTest/UnitTest.csproj # tests target net9.0, MSTest +dotnet test UnitTest/UnitTest.csproj --filter "FullyQualifiedName~MessageBusTest.MethodName" # single test +``` + +Building the full `DSoft.Messaging` project requires platform workloads (`dotnet workload restore`) because it multi-targets iOS/Android/macOS/tvos/MacCatalyst/Windows. To iterate quickly without workloads, build/test against a single TFM via the UnitTest project (which references the main project but is consumed as `net9.0`). Note `Directory.Build.props` sets `GeneratePackageOnBuild=true` and signs assemblies with `DSoft.snk`. + +## Architecture + +Three source projects plus a WPF sample: + +- **DSoft.MessageBus.Core** (`netstandard2.0`) — platform-agnostic contracts and data types: `IMessageBusService`, the abstract `MessageBusEvent` + concrete `CoreMessageBusEvent`, `MessageBusEventHandler`/`TypedMessageBusEventHandler`, `LogEvent`, `ILogListener`, and the handler/listener collections. Namespace is `DSoft.MessageBus` (not `.Core`). +- **DSoft.Messaging** (multi-target, PackageId `DSoft.MessageBus`) — the runtime: `MessageBusService` (internal impl of `IMessageBusService`), the static `MessageBus` facade, DI registration, and `ThreadControl`. +- **UnitTest** — MSTest. `BaseTest` builds a DI `ServiceProvider` via `RegisterMessageBus()` in `[AssemblyInitialize]`; resolve `IMessageBusService` from `BaseTest.Provider`. + +### Two ways to consume the bus +1. **Static facade** — `MessageBus.Post/Subscribe/...` wraps a lazily-created singleton `MessageBusService`. +2. **DI** — call `services.RegisterMessageBus()` (extension in `Microsoft.Extensions.DependencyInjection` namespace) to register `IMessageBusService` as a singleton, then inject it. + +Both routes hit the same `MessageBusService` logic. + +### Event dispatch model +- **String-id events**: subscribe/post by `eventId` string. Matching is **case-insensitive** (`HandlersForEvent` lowercases both sides). `CoreMessageBusEvent` auto-generates a GUID id if none supplied. +- **Typed events**: subclass `MessageBusEvent`, override `EventId`, subscribe with `Subscribe()`. These register as `TypedMessageBusEventHandler` and dispatch by `Type`. In `PostInternal`, a non-`CoreMessageBusEvent` is delivered to both type handlers **and** any handlers registered for its `EventId`. +- `RunPostOnSeperateTask` (default false) offloads `Post`/`Log` to `Task.Run`; `PostAsync` variants always run on a background task. + +### Platform-specific compilation +`DSoft.Messaging` uses **filename-suffix conventions** instead of one file per TFM — the `.csproj` includes files by suffix via `EnableDefaultCompileItems=false`: +- `*.shared.cs` — all targets +- `*.netstandard.cs` — netstandard/net8/net9/net10/net472 +- `*.wpf.cs` (windows7.0), `*.winui.cs` (windows10), `*.android.cs`, `*.ios.cs` (ios+maccatalyst), `*.mac.cs` (macos), `*.tvos.cs` + +`ThreadControl` is the main example: `ThreadControl.shared.cs` defines the public API and calls `partial` members (`PlatformIsMainThread`, `PlatformBeginInvokeOnMainThread`) implemented per platform. When adding platform-specific code, add a new `*..cs` partial rather than `#if`. (ThreadControl's `MainThread` logic is adapted from Xamarin.Essentials.) + +## Conventions +- Keep `IMessageBusService`, the static `MessageBus` facade, and `MessageBusService` in sync — all three expose the same Post/Subscribe/Unsubscribe/Log surface. +- New public APIs need XML doc comments (`GenerateDocumentationFile=true`; many nullable/doc warnings are suppressed in `Directory.Build.props`). +- Version/release notes live in the `.csproj` PropertyGroups (`Version`, `AssemblyVersion`, `PackageReleaseNotes`); bump there for releases. diff --git a/DSoft.MessageBus.Core/Collections/LogListernersCollection.shared.cs b/DSoft.MessageBus.Core/Collections/LogListernersCollection.shared.cs index 368642d..d41dcb4 100644 --- a/DSoft.MessageBus.Core/Collections/LogListernersCollection.shared.cs +++ b/DSoft.MessageBus.Core/Collections/LogListernersCollection.shared.cs @@ -8,25 +8,35 @@ namespace DSoft.MessageBus { public class LogListernersCollection : Collection { + private readonly object _syncRoot = new object(); + + /// + /// Lock object guarding access to this collection. + /// + public object SyncRoot => _syncRoot; + public void Register(ILogListener instance) { - if (this.Contains(instance)) - return; - if (instance.Channels == null || instance.Channels.Count() == 0) throw new Exception($"Cannot register {instance.GetType().FullName} as an ILogListener as it has no channels to listen too"); - this.Add(instance); + lock (_syncRoot) + { + if (this.Contains(instance)) + return; + + this.Add(instance); + } } public IEnumerable FindAll(string channelName) { - var results = from item in this.Items - where item.Channels.Contains(channelName, StringComparer.OrdinalIgnoreCase) - select item; - - - return results; + lock (_syncRoot) + { + return this.Items + .Where(item => item.Channels.Contains(channelName, StringComparer.OrdinalIgnoreCase)) + .ToArray(); + } } } } diff --git a/DSoft.MessageBus.Core/Collections/MessageBusEventHandlerCollection.shared.cs b/DSoft.MessageBus.Core/Collections/MessageBusEventHandlerCollection.shared.cs index 2bfe168..9ba7265 100644 --- a/DSoft.MessageBus.Core/Collections/MessageBusEventHandlerCollection.shared.cs +++ b/DSoft.MessageBus.Core/Collections/MessageBusEventHandlerCollection.shared.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using System.Collections.ObjectModel; using System.Collections.Generic; @@ -10,6 +9,22 @@ namespace DSoft.MessageBus /// public class MessageBusEventHandlerCollection : Collection { + #region Fields + + private readonly object _syncRoot = new object (); + + #endregion + + #region Properties + + /// + /// Lock object guarding access to this collection. Callers performing + /// compound (check-then-act) operations should lock on this. + /// + public object SyncRoot => _syncRoot; + + #endregion + #region Methods /// @@ -19,14 +34,23 @@ public class MessageBusEventHandlerCollection : Collection public MessageBusEventHandler[] HandlersForEvent (String EventId) { - var results = from item in this.Items - where !String.IsNullOrWhiteSpace (item.EventId) - where item.EventId.ToLower ().Equals (EventId.ToLower ()) - where item.EventAction != null - select item; - - var array = results.ToArray (); - return array; + List results = null; + + lock (_syncRoot) + { + foreach (var item in this.Items) + { + if (item.EventAction == null || String.IsNullOrWhiteSpace (item.EventId)) + continue; + + if (!String.Equals (item.EventId, EventId, StringComparison.OrdinalIgnoreCase)) + continue; + + (results ??= new List ()).Add (item); + } + } + + return results == null ? Array.Empty () : results.ToArray (); } /// @@ -36,22 +60,23 @@ where item.EventId.ToLower ().Equals (EventId.ToLower ()) /// Event type. public MessageBusEventHandler[] HandlersForEvent (Type EventType) { - var results = from item in this.Items - where item is TypedMessageBusEventHandler - where item.EventAction != null - select item; - - var list = new List (); + List results = null; - foreach (TypedMessageBusEventHandler item in results.ToArray()) + lock (_syncRoot) { - if (item.EventType != null && item.EventType.Equals (EventType)) + foreach (var item in this.Items) { - list.Add (item); + if (item.EventAction == null || !(item is TypedMessageBusEventHandler typed)) + continue; + + if (typed.EventType == null || !typed.EventType.Equals (EventType)) + continue; + + (results ??= new List ()).Add (typed); } } - return list.ToArray (); + return results == null ? Array.Empty () : results.ToArray (); } /// diff --git a/DSoft.Messaging/DSoft.MessageBus.csproj b/DSoft.Messaging/DSoft.MessageBus.csproj index 9c3198b..8df354b 100644 --- a/DSoft.Messaging/DSoft.MessageBus.csproj +++ b/DSoft.Messaging/DSoft.MessageBus.csproj @@ -76,8 +76,8 @@ - - + + @@ -105,7 +105,7 @@ - + diff --git a/DSoft.Messaging/MessageBusService.shared.cs b/DSoft.Messaging/MessageBusService.shared.cs index b68805f..1c3caea 100644 --- a/DSoft.Messaging/MessageBusService.shared.cs +++ b/DSoft.Messaging/MessageBusService.shared.cs @@ -239,11 +239,14 @@ public Task PostAsync(string eventId, object Sender, params object[] Data) /// Handler action public void Unsubscribe(string eventId, Action action) { - foreach (var item in FindHandlersForEvent(eventId)) + lock (EventHandlers.SyncRoot) { - if (item.EventAction.Equals(action)) + foreach (var item in FindHandlersForEvent(eventId)) { - EventHandlers.Remove(item); + if (item.EventAction.Equals(action)) + { + EventHandlers.Remove(item); + } } } } @@ -254,9 +257,12 @@ public void Unsubscribe(string eventId, Action action) /// The event handler instance public void Unsubscribe(MessageBusEventHandler EventHandler) { - if (EventHandlers.Contains(EventHandler)) + lock (EventHandlers.SyncRoot) { - EventHandlers.Remove(EventHandler); + if (EventHandlers.Contains(EventHandler)) + { + EventHandlers.Remove(EventHandler); + } } } @@ -268,13 +274,16 @@ public void Unsubscribe(MessageBusEventHandler EventHandler) /// The action to remove public void Unsubscribe(Action Action) where T : MessageBusEvent { - var results = new List(EventHandlers.HandlersForEvent()); - - foreach (var item in results) + lock (EventHandlers.SyncRoot) { - if (item.EventAction == Action) + var results = new List(EventHandlers.HandlersForEvent()); + + foreach (var item in results) { - EventHandlers.Remove(item); + if (item.EventAction == Action) + { + EventHandlers.Remove(item); + } } } } @@ -285,9 +294,12 @@ public void Unsubscribe(Action Action) where T : Mes /// Event identifier public void Unsubscribe(string eventId) { - foreach (var item in FindHandlersForEvent(eventId)) + lock (EventHandlers.SyncRoot) { - EventHandlers.Remove(item); + foreach (var item in FindHandlersForEvent(eventId)) + { + EventHandlers.Remove(item); + } } } #endregion @@ -340,9 +352,12 @@ public void Subscribe(MessageBusEventHandler EventHandler) if (EventHandler == null) return; - if (!EventHandlers.Contains(EventHandler)) + lock (EventHandlers.SyncRoot) { - EventHandlers.Add(EventHandler); + if (!EventHandlers.Contains(EventHandler)) + { + EventHandlers.Add(EventHandler); + } } } /// @@ -435,7 +450,10 @@ public void StopListening(ILogListener instance) if (LogListeners == null) return; - LogListeners.Remove(instance); + lock (LogListeners.SyncRoot) + { + LogListeners.Remove(instance); + } } #endregion diff --git a/UnitTest/ConcurrencyTest.cs b/UnitTest/ConcurrencyTest.cs new file mode 100644 index 0000000..c035146 --- /dev/null +++ b/UnitTest/ConcurrencyTest.cs @@ -0,0 +1,104 @@ +using DSoft.MessageBus; +using DSoft.MessageBus.Contracts; +using Microsoft.Extensions.DependencyInjection; + +namespace UnitTest +{ + [TestClass] + public class ConcurrencyTest + { + private static IMessageBusService NewBus() + { + var services = new ServiceCollection(); + services.RegisterMessageBus(); + return services.BuildServiceProvider().GetRequiredService(); + } + + /// + /// Hammers Subscribe/Unsubscribe on the same event id from many threads while + /// other threads Post continuously. Before the lock fix this raced the + /// underlying Collection<T> and threw "Collection was modified" / + /// IndexOutOfRange. Passing means no handler-collection corruption. + /// + [DataTestMethod] + [DataRow(1)] + [DataRow(2)] + [DataRow(3)] + [DataRow(4)] + [DataRow(5)] + [DataRow(6)] + [DataRow(7)] + [DataRow(8)] + [DataRow(9)] + [DataRow(10)] + public void ConcurrentSubscribeUnsubscribeAndPost_DoesNotThrow(int run) + { + _ = run; + var bus = NewBus(); + var eventId = "concurrency-" + Guid.NewGuid(); + + const int iterations = 5000; + var exceptions = new System.Collections.Concurrent.ConcurrentQueue(); + + Action noop = (sender, evt) => { }; + + // Writers: churn the handler collection. + var subscribers = Enumerable.Range(0, 4).Select(_ => Task.Run(() => + { + try + { + for (var i = 0; i < iterations; i++) + { + var handler = new MessageBusEventHandler(eventId, (s, e) => { }); + bus.Subscribe(handler); + bus.Unsubscribe(handler); + } + } + catch (Exception ex) { exceptions.Enqueue(ex); } + })); + + // Readers: dispatch continuously across the churning collection. + var posters = Enumerable.Range(0, 4).Select(_ => Task.Run(() => + { + try + { + for (var i = 0; i < iterations; i++) + bus.Post(eventId, this); + } + catch (Exception ex) { exceptions.Enqueue(ex); } + })); + + Task.WaitAll(subscribers.Concat(posters).ToArray()); + + Assert.IsTrue(exceptions.IsEmpty, + "Concurrent access threw: " + string.Join("; ", exceptions.Select(e => e.GetType().Name + ": " + e.Message))); + } + + /// + /// A handler registered once must fire on every Post issued in parallel. + /// Verifies dispatch stays correct (no lost/dropped invocations) under load. + /// + [TestMethod] + public void ConcurrentPosts_AllReachSubscriber() + { + var bus = NewBus(); + var eventId = "concurrency-count-" + Guid.NewGuid(); + + const int postsPerThread = 2000; + const int threads = 8; + var received = 0; + + bus.Subscribe(eventId, (s, e) => Interlocked.Increment(ref received)); + + var tasks = Enumerable.Range(0, threads).Select(_ => Task.Run(() => + { + for (var i = 0; i < postsPerThread; i++) + bus.Post(eventId, this); + })); + + Task.WaitAll(tasks.ToArray()); + + Assert.AreEqual(threads * postsPerThread, received); + } + } +} diff --git a/UnitTest/UnitTest.csproj b/UnitTest/UnitTest.csproj index 0847a30..159e8d9 100644 --- a/UnitTest/UnitTest.csproj +++ b/UnitTest/UnitTest.csproj @@ -11,11 +11,14 @@ - - - - - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/WPFSample/WPFSample.csproj b/WPFSample/WPFSample.csproj index 9a1f577..b8529b7 100644 --- a/WPFSample/WPFSample.csproj +++ b/WPFSample/WPFSample.csproj @@ -10,7 +10,7 @@ - + diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml index 7d2abb3..5ad09f5 100644 --- a/azure-pipelines-release.yml +++ b/azure-pipelines-release.yml @@ -22,7 +22,7 @@ variables: netVersion: '10.x' releaseSuffix: '' -name: 4.3.$(date:yyMM).$(date:dd)$(rev:r) +name: 4.4.$(date:yyMM).$(date:dd)$(rev:r) steps: - task: NuGetToolInstaller@1 displayName: Install Latest Nuget