WeakReferenceMessenger.cs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. // Licensed to the .NET Foundation under one or more agreements.
  2. // The .NET Foundation licenses this file to you under the MIT license.
  3. // See the LICENSE file in the project root for more information.
  4. using System;
  5. using System.Collections.Generic;
  6. using System.Runtime.CompilerServices;
  7. using System.Runtime.InteropServices;
  8. using System.Threading;
  9. using CommunityToolkit.Mvvm.Messaging.Internals;
  10. namespace CommunityToolkit.Mvvm.Messaging;
  11. /// <summary>
  12. /// A class providing a reference implementation for the <see cref="IMessenger"/> interface.
  13. /// </summary>
  14. /// <remarks>
  15. /// <para>
  16. /// This <see cref="IMessenger"/> implementation uses weak references to track the registered
  17. /// recipients, so it is not necessary to manually unregister them when they're no longer needed.
  18. /// </para>
  19. /// <para>
  20. /// The <see cref="WeakReferenceMessenger"/> type will automatically perform internal trimming when
  21. /// full GC collections are invoked, so calling <see cref="Cleanup"/> manually is not necessary to
  22. /// ensure that on average the internal data structures are as trimmed and compact as possible.
  23. /// </para>
  24. /// </remarks>
  25. public sealed class WeakReferenceMessenger : IMessenger
  26. {
  27. // This messenger uses the following logic to link stored instances together:
  28. // --------------------------------------------------------------------------------------------------------
  29. // Dictionary2<TToken, MessageHandlerDispatcher?> mapping
  30. // / / /
  31. // ___(Type2.TToken)___/ / / ___(if Type2.TToken is Unit)
  32. // /_________(Type2.TMessage)______________/ / /
  33. // / _________________/___MessageHandlerDispatcher?
  34. // / / \
  35. // Dictionary2<Type2, ConditionalWeakTable<object, object?>> recipientsMap; \___(null if using IRecipient<TMessage>)
  36. // --------------------------------------------------------------------------------------------------------
  37. // Just like in the strong reference variant, each pair of message and token types is used as a key in the
  38. // recipients map. In this case, the values in the dictionary are ConditionalWeakTable2<,> instances, that
  39. // link each registered recipient to a map of currently registered handlers, through dependent handles. This
  40. // ensures that handlers will remain alive as long as their associated recipient is also alive (so there is no
  41. // need for users to manually indicate whether a given handler should be kept alive in case it creates a closure).
  42. // The value in each conditional table can either be Dictionary2<TToken, MessageHandlerDispatcher> or object. The
  43. // first case is used when any token type other than the default Unit type is used, as in this case there could be
  44. // multiple handlers for each recipient that need to be tracked separately. In order to invoke all the handlers from
  45. // a context where their type parameters is not known, handlers are stored as MessageHandlerDispatcher instances. There
  46. // are two possible cases here: either a given instance is of type MessageHandlerDispatcher.For<TRecipient, TMessage>,
  47. // or null. The first is the default case: whenever a subscription is done with a MessageHandler<TRecipient, TToken>,
  48. // that delegate is wrapped in an instance of this class so that it can keep track internally of the generic context in
  49. // use, so that it can be retrieved when the callback is executed. If the subscription is done directly on a recipient
  50. // that implements IRecipient<TMessage instead, the dispatcher is null, which just acts as marker. Whenever the broadcast
  51. // method finds it, it will just invoke IRecipient<TMessage.Receive directly on the target recipient, which avoids the
  52. // extra indirection on dispatch as well as having to allocate an extra wrapper type for the handler. Lastly, there is a
  53. // special case when subscriptions are done through the Unit type, meaning when the default channel is in use. In this
  54. // case, each recipient only stores a single MessageHandlerDispatcher instance and not a whole dictionary, as there can
  55. // only ever be a single handler for each recipient.
  56. /// <summary>
  57. /// The map of currently registered recipients for all message types.
  58. /// </summary>
  59. private readonly Dictionary2<Type2, ConditionalWeakTable2<object, object?>> recipientsMap = new();
  60. /// <summary>
  61. /// Initializes a new instance of the <see cref="WeakReferenceMessenger"/> class.
  62. /// </summary>
  63. public WeakReferenceMessenger()
  64. {
  65. // Proxy function for the GC callback. This needs to be static and to take the target instance as
  66. // an input parameter in order to avoid rooting it from the Gen2GcCallback object invoking it.
  67. static void Gen2GcCallbackProxy(object target)
  68. {
  69. ((WeakReferenceMessenger)target).CleanupWithNonBlockingLock();
  70. }
  71. // Register an automatic GC callback to trigger a non-blocking cleanup. This will ensure that the
  72. // current messenger instance is trimmed and without leftover recipient maps that are no longer used.
  73. // This is necessary (as in, some form of cleanup, either explicit or automatic like in this case)
  74. // because the ConditionalWeakTable<TKey, TValue> instances will just remove key-value pairs on their
  75. // own as soon as a key (ie. a recipient) is collected, causing their own keys (ie. the Type2 instances
  76. // mapping to each conditional table for a pair of message and token types) to potentially remain in the
  77. // root mapping structure but without any remaining recipients actually registered there, which just
  78. // adds unnecessary overhead when trying to enumerate recipients during broadcasting operations later on.
  79. Gen2GcCallback.Register(Gen2GcCallbackProxy, this);
  80. }
  81. /// <summary>
  82. /// Gets the default <see cref="WeakReferenceMessenger"/> instance.
  83. /// </summary>
  84. public static WeakReferenceMessenger Default { get; } = new();
  85. /// <inheritdoc/>
  86. public bool IsRegistered<TMessage, TToken>(object recipient, TToken token)
  87. where TMessage : class
  88. where TToken : IEquatable<TToken>
  89. {
  90. ArgumentNullException.ThrowIfNull(recipient);
  91. ArgumentNullException.For<TToken>.ThrowIfNull(token);
  92. lock (this.recipientsMap)
  93. {
  94. Type2 type2 = new(typeof(TMessage), typeof(TToken));
  95. // Get the conditional table associated with the target recipient, for the current pair
  96. // of token and message types. If it exists, check if there is a matching token.
  97. if (!this.recipientsMap.TryGetValue(type2, out ConditionalWeakTable2<object, object?>? table))
  98. {
  99. return false;
  100. }
  101. // Special case for unit tokens
  102. if (typeof(TToken) == typeof(Unit))
  103. {
  104. return table.TryGetValue(recipient, out _);
  105. }
  106. // Custom token type, so each recipient has an associated map
  107. return
  108. table.TryGetValue(recipient, out object? mapping) &&
  109. Unsafe.As<Dictionary2<TToken, object?>>(mapping!).ContainsKey(token);
  110. }
  111. }
  112. /// <inheritdoc/>
  113. public void Register<TRecipient, TMessage, TToken>(TRecipient recipient, TToken token, MessageHandler<TRecipient, TMessage> handler)
  114. where TRecipient : class
  115. where TMessage : class
  116. where TToken : IEquatable<TToken>
  117. {
  118. ArgumentNullException.ThrowIfNull(recipient);
  119. ArgumentNullException.For<TToken>.ThrowIfNull(token);
  120. ArgumentNullException.ThrowIfNull(handler);
  121. Register<TMessage, TToken>(recipient, token, new MessageHandlerDispatcher.For<TRecipient, TMessage>(handler));
  122. }
  123. /// <summary>
  124. /// Registers a recipient for a given type of message.
  125. /// </summary>
  126. /// <typeparam name="TMessage">The type of message to receive.</typeparam>
  127. /// <typeparam name="TToken">The type of token to use to pick the messages to receive.</typeparam>
  128. /// <param name="recipient">The recipient that will receive the messages.</param>
  129. /// <param name="token">A token used to determine the receiving channel to use.</param>
  130. /// <exception cref="InvalidOperationException">Thrown when trying to register the same message twice.</exception>
  131. /// <remarks>
  132. /// This method is a variation of <see cref="Register{TRecipient, TMessage, TToken}(TRecipient, TToken, MessageHandler{TRecipient, TMessage})"/>
  133. /// that is specialized for recipients implementing <see cref="IRecipient{TMessage}"/>. See more comments at the top of this type, as well as
  134. /// within <see cref="Send{TMessage, TToken}(TMessage, TToken)"/> and in the <see cref="MessageHandlerDispatcher"/> types.
  135. /// </remarks>
  136. internal void Register<TMessage, TToken>(IRecipient<TMessage> recipient, TToken token)
  137. where TMessage : class
  138. where TToken : IEquatable<TToken>
  139. {
  140. Register<TMessage, TToken>(recipient, token, null);
  141. }
  142. /// <summary>
  143. /// Registers a recipient for a given type of message.
  144. /// </summary>
  145. /// <typeparam name="TMessage">The type of message to receive.</typeparam>
  146. /// <typeparam name="TToken">The type of token to use to pick the messages to receive.</typeparam>
  147. /// <param name="recipient">The recipient that will receive the messages.</param>
  148. /// <param name="token">A token used to determine the receiving channel to use.</param>
  149. /// <param name="dispatcher">The input <see cref="MessageHandlerDispatcher"/> instance to register, or null.</param>
  150. /// <exception cref="InvalidOperationException">Thrown when trying to register the same message twice.</exception>
  151. private void Register<TMessage, TToken>(object recipient, TToken token, MessageHandlerDispatcher? dispatcher)
  152. where TMessage : class
  153. where TToken : IEquatable<TToken>
  154. {
  155. lock (this.recipientsMap)
  156. {
  157. Type2 type2 = new(typeof(TMessage), typeof(TToken));
  158. // Get the conditional table for the pair of type arguments, or create it if it doesn't exist
  159. ref ConditionalWeakTable2<object, object?>? mapping = ref this.recipientsMap.GetOrAddValueRef(type2);
  160. mapping ??= new ConditionalWeakTable2<object, object?>();
  161. // Fast path for unit tokens
  162. if (typeof(TToken) == typeof(Unit))
  163. {
  164. if (!mapping.TryAdd(recipient, dispatcher))
  165. {
  166. ThrowInvalidOperationExceptionForDuplicateRegistration();
  167. }
  168. }
  169. else
  170. {
  171. // Get or create the handlers dictionary for the target recipient
  172. Dictionary2<TToken, object?>? map = Unsafe.As<Dictionary2<TToken, object?>>(mapping.GetValue(recipient, static _ => new Dictionary2<TToken, object?>())!);
  173. // Add the new registration entry
  174. ref object? registeredHandler = ref map.GetOrAddValueRef(token);
  175. if (registeredHandler is not null)
  176. {
  177. ThrowInvalidOperationExceptionForDuplicateRegistration();
  178. }
  179. // Store the input handler
  180. registeredHandler = dispatcher;
  181. }
  182. }
  183. }
  184. /// <inheritdoc/>
  185. public void UnregisterAll(object recipient)
  186. {
  187. ArgumentNullException.ThrowIfNull(recipient);
  188. lock (this.recipientsMap)
  189. {
  190. Dictionary2<Type2, ConditionalWeakTable2<object, object?>>.Enumerator enumerator = this.recipientsMap.GetEnumerator();
  191. // Traverse all the existing conditional tables and remove all the ones
  192. // with the target recipient as key. We don't perform a cleanup here,
  193. // as that is responsibility of a separate method defined below.
  194. while (enumerator.MoveNext())
  195. {
  196. _ = enumerator.GetValue().Remove(recipient);
  197. }
  198. }
  199. }
  200. /// <inheritdoc/>
  201. public void UnregisterAll<TToken>(object recipient, TToken token)
  202. where TToken : IEquatable<TToken>
  203. {
  204. ArgumentNullException.ThrowIfNull(recipient);
  205. ArgumentNullException.For<TToken>.ThrowIfNull(token);
  206. // This method is never called with the unit type. See more details in
  207. // the comments in the corresponding method in StrongReferenceMessenger.
  208. if (typeof(TToken) == typeof(Unit))
  209. {
  210. throw new NotImplementedException();
  211. }
  212. lock (this.recipientsMap)
  213. {
  214. Dictionary2<Type2, ConditionalWeakTable2<object, object?>>.Enumerator enumerator = this.recipientsMap.GetEnumerator();
  215. // Same as above, with the difference being that this time we only go through
  216. // the conditional tables having a matching token type as key, and that we
  217. // only try to remove handlers with a matching token, if any.
  218. while (enumerator.MoveNext())
  219. {
  220. if (enumerator.GetKey().TToken == typeof(TToken))
  221. {
  222. if (enumerator.GetValue().TryGetValue(recipient, out object? mapping))
  223. {
  224. _ = Unsafe.As<Dictionary2<TToken, object?>>(mapping!).TryRemove(token);
  225. }
  226. }
  227. }
  228. }
  229. }
  230. /// <inheritdoc/>
  231. public void Unregister<TMessage, TToken>(object recipient, TToken token)
  232. where TMessage : class
  233. where TToken : IEquatable<TToken>
  234. {
  235. ArgumentNullException.ThrowIfNull(recipient);
  236. ArgumentNullException.For<TToken>.ThrowIfNull(token);
  237. lock (this.recipientsMap)
  238. {
  239. Type2 type2 = new(typeof(TMessage), typeof(TToken));
  240. // Get the target mapping table for the combination of message and token types,
  241. // and remove the handler with a matching token (the entire map), if present.
  242. if (this.recipientsMap.TryGetValue(type2, out ConditionalWeakTable2<object, object?>? value))
  243. {
  244. if (typeof(TToken) == typeof(Unit))
  245. {
  246. _ = value.Remove(recipient);
  247. }
  248. else if (value.TryGetValue(recipient, out object? mapping))
  249. {
  250. _ = Unsafe.As<Dictionary2<TToken, object?>>(mapping!).TryRemove(token);
  251. }
  252. }
  253. }
  254. }
  255. /// <inheritdoc/>
  256. public TMessage Send<TMessage, TToken>(TMessage message, TToken token)
  257. where TMessage : class
  258. where TToken : IEquatable<TToken>
  259. {
  260. ArgumentNullException.ThrowIfNull(message);
  261. ArgumentNullException.For<TToken>.ThrowIfNull(token);
  262. ArrayPoolBufferWriter<object?> bufferWriter;
  263. int i = 0;
  264. lock (this.recipientsMap)
  265. {
  266. Type2 type2 = new(typeof(TMessage), typeof(TToken));
  267. // Try to get the target table
  268. if (!this.recipientsMap.TryGetValue(type2, out ConditionalWeakTable2<object, object?>? table))
  269. {
  270. return message;
  271. }
  272. bufferWriter = ArrayPoolBufferWriter<object?>.Create();
  273. // We need a local, temporary copy of all the pending recipients and handlers to
  274. // invoke, to avoid issues with handlers unregistering from messages while we're
  275. // holding the lock. To do this, we can just traverse the conditional table in use
  276. // to enumerate all the existing recipients for the token and message types pair
  277. // corresponding to the generic arguments for this invocation, and then track the
  278. // handlers with a matching token, and their corresponding recipients.
  279. using ConditionalWeakTable2<object, object?>.Enumerator enumerator = table.GetEnumerator();
  280. while (enumerator.MoveNext())
  281. {
  282. if (typeof(TToken) == typeof(Unit))
  283. {
  284. bufferWriter.Add(enumerator.GetValue());
  285. bufferWriter.Add(enumerator.GetKey());
  286. i++;
  287. }
  288. else
  289. {
  290. Dictionary2<TToken, object?>? map = Unsafe.As<Dictionary2<TToken, object?>>(enumerator.GetValue()!);
  291. if (map.TryGetValue(token, out object? handler))
  292. {
  293. bufferWriter.Add(handler);
  294. bufferWriter.Add(enumerator.GetKey());
  295. i++;
  296. }
  297. }
  298. }
  299. }
  300. try
  301. {
  302. SendAll(bufferWriter.Span, i, message);
  303. }
  304. finally
  305. {
  306. bufferWriter.Dispose();
  307. }
  308. return message;
  309. }
  310. /// <summary>
  311. /// Implements the broadcasting logic for <see cref="Send{TMessage, TToken}(TMessage, TToken)"/>.
  312. /// </summary>
  313. /// <typeparam name="TMessage"></typeparam>
  314. /// <param name="pairs"></param>
  315. /// <param name="i"></param>
  316. /// <param name="message"></param>
  317. /// <remarks>
  318. /// This method is not a local function to avoid triggering multiple compilations due to <c>TToken</c>
  319. /// potentially being a value type, which results in specialized code due to reified generics. This is
  320. /// necessary to work around a Roslyn limitation that causes unnecessary type parameters in local
  321. /// functions not to be discarded in the synthesized methods. Additionally, keeping this loop outside
  322. /// of the EH block (the <see langword="try"/> block) can help result in slightly better codegen.
  323. /// </remarks>
  324. [MethodImpl(MethodImplOptions.NoInlining)]
  325. internal static void SendAll<TMessage>(ReadOnlySpan<object?> pairs, int i, TMessage message)
  326. where TMessage : class
  327. {
  328. // This Slice calls executes bounds checks for the loop below, in case i was somehow wrong.
  329. // The rest of the implementation relies on bounds checks removal and loop strength reduction
  330. // done manually (which results in a 20% speedup during broadcast), since the JIT is not able
  331. // to recognize this pattern. Skipping checks below is a provably safe optimization: the slice
  332. // has exactly 2 * i elements (due to this slicing), and each loop iteration processes a pair.
  333. // The loops ends when the initial reference reaches the end, and that's incremented by 2 at
  334. // the end of each iteration. The target being a span, obviously means the length is constant.
  335. ReadOnlySpan<object?> slice = pairs.Slice(0, 2 * i);
  336. ref object? sliceStart = ref MemoryMarshal.GetReference(slice);
  337. ref object? sliceEnd = ref Unsafe.Add(ref sliceStart, slice.Length);
  338. while (Unsafe.IsAddressLessThan(ref sliceStart, ref sliceEnd))
  339. {
  340. object? handler = sliceStart;
  341. object recipient = Unsafe.Add(ref sliceStart, 1)!;
  342. // Here we need to distinguish the two possible cases: either the recipient was registered
  343. // through the IRecipient<TMessage> interface, or with a custom handler. In the first case,
  344. // the handler stored in the messenger is just null, so we can check that and branch to a
  345. // fast path that just invokes IRecipient<TMessage> directly on the recipient. Otherwise,
  346. // we will use the standard double dispatch approach. This check is particularly convenient
  347. // as we only need to check for null to determine what registration type was used, without
  348. // having to store any additional info in the messenger. This will produce code as follows,
  349. // with the advantage of also being compact and not having to use any additional registers:
  350. // =============================
  351. // L0000: test rcx, rcx
  352. // L0003: jne short L0040
  353. // =============================
  354. // Which is extremely fast. The reason for this conditional check in the first place is that
  355. // we're doing manual (null based) guarded devirtualization: if the handler is the marker
  356. // type and not an actual handler then we know that the recipient implements
  357. // IRecipient<TMessage>, so we can just cast to it and invoke it directly. This avoids
  358. // having to store the proxy callback when registering, and also skips an indirection
  359. // (invoking the delegate that then invokes the actual method). Additional note: this
  360. // pattern ensures that both casts below do not actually alias incompatible reference
  361. // types (as in, they would both succeed if they were safe casts), which lets the code
  362. // not rely on undefined behavior to run correctly (ie. we're not aliasing delegates).
  363. if (handler is null)
  364. {
  365. Unsafe.As<IRecipient<TMessage>>(recipient).Receive(message);
  366. }
  367. else
  368. {
  369. Unsafe.As<MessageHandlerDispatcher>(handler).Invoke(recipient, message);
  370. }
  371. sliceStart = ref Unsafe.Add(ref sliceStart, 2);
  372. }
  373. }
  374. /// <inheritdoc/>
  375. public void Cleanup()
  376. {
  377. lock (this.recipientsMap)
  378. {
  379. CleanupWithoutLock();
  380. }
  381. }
  382. /// <inheritdoc/>
  383. public void Reset()
  384. {
  385. lock (this.recipientsMap)
  386. {
  387. this.recipientsMap.Clear();
  388. }
  389. }
  390. /// <summary>
  391. /// Executes a cleanup without locking the current instance. This method has to be
  392. /// invoked when a lock on <see cref="recipientsMap"/> has already been acquired.
  393. /// </summary>
  394. private void CleanupWithNonBlockingLock()
  395. {
  396. object lockObject = this.recipientsMap;
  397. bool lockTaken = false;
  398. try
  399. {
  400. Monitor.TryEnter(lockObject, ref lockTaken);
  401. if (lockTaken)
  402. {
  403. CleanupWithoutLock();
  404. }
  405. }
  406. finally
  407. {
  408. if (lockTaken)
  409. {
  410. Monitor.Exit(lockObject);
  411. }
  412. }
  413. }
  414. /// <summary>
  415. /// Executes a cleanup without locking the current instance. This method has to be
  416. /// invoked when a lock on <see cref="recipientsMap"/> has already been acquired.
  417. /// </summary>
  418. private void CleanupWithoutLock()
  419. {
  420. using ArrayPoolBufferWriter<Type2> type2s = ArrayPoolBufferWriter<Type2>.Create();
  421. using ArrayPoolBufferWriter<object> emptyRecipients = ArrayPoolBufferWriter<object>.Create();
  422. Dictionary2<Type2, ConditionalWeakTable2<object, object?>>.Enumerator type2Enumerator = this.recipientsMap.GetEnumerator();
  423. // First, we go through all the currently registered pairs of token and message types.
  424. // These represents all the combinations of generic arguments with at least one registered
  425. // handler, with the exception of those with recipients that have already been collected.
  426. while (type2Enumerator.MoveNext())
  427. {
  428. emptyRecipients.Reset();
  429. bool hasAtLeastOneHandler = false;
  430. if (type2Enumerator.GetKey().TToken == typeof(Unit))
  431. {
  432. // When the token type is unit, there can be no registered recipients with no handlers,
  433. // as when the single handler is unsubscribed the recipient is also removed immediately.
  434. // Therefore, we need to check that there exists at least one recipient for the message.
  435. using ConditionalWeakTable2<object, object?>.Enumerator recipientsEnumerator = type2Enumerator.GetValue().GetEnumerator();
  436. while (recipientsEnumerator.MoveNext())
  437. {
  438. hasAtLeastOneHandler = true;
  439. break;
  440. }
  441. }
  442. else
  443. {
  444. // Go through the currently alive recipients to look for those with no handlers left. We track
  445. // the ones we find to remove them outside of the loop (can't modify during enumeration).
  446. using (ConditionalWeakTable2<object, object?>.Enumerator recipientsEnumerator = type2Enumerator.GetValue().GetEnumerator())
  447. {
  448. while (recipientsEnumerator.MoveNext())
  449. {
  450. if (Unsafe.As<IDictionary2>(recipientsEnumerator.GetValue()!).Count == 0)
  451. {
  452. emptyRecipients.Add(recipientsEnumerator.GetKey());
  453. }
  454. else
  455. {
  456. hasAtLeastOneHandler = true;
  457. }
  458. }
  459. }
  460. // Remove the handler maps for recipients that are still alive but with no handlers
  461. foreach (object recipient in emptyRecipients.Span)
  462. {
  463. _ = type2Enumerator.GetValue().Remove(recipient);
  464. }
  465. }
  466. // Track the type combinations with no recipients or handlers left
  467. if (!hasAtLeastOneHandler)
  468. {
  469. type2s.Add(type2Enumerator.GetKey());
  470. }
  471. }
  472. // Remove all the mappings with no handlers left
  473. foreach (Type2 key in type2s.Span)
  474. {
  475. _ = this.recipientsMap.TryRemove(key);
  476. }
  477. }
  478. /// <summary>
  479. /// Throws an <see cref="InvalidOperationException"/> when trying to add a duplicate handler.
  480. /// </summary>
  481. private static void ThrowInvalidOperationExceptionForDuplicateRegistration()
  482. {
  483. throw new InvalidOperationException("The target recipient has already subscribed to the target message.");
  484. }
  485. }