diff --git a/library/src/Core/Common/Common.csproj b/library/src/Core/Common/Common.csproj
index 6f260d34..38f9ba7d 100644
--- a/library/src/Core/Common/Common.csproj
+++ b/library/src/Core/Common/Common.csproj
@@ -24,6 +24,7 @@
+
diff --git a/library/src/Core/Common/Util/LogUtilities.cs b/library/src/Core/Common/Util/LogUtilities.cs
new file mode 100644
index 00000000..b2cddec7
--- /dev/null
+++ b/library/src/Core/Common/Util/LogUtilities.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Collections.Concurrent;
+using System.Linq;
+using System.Text;
+using NLog;
+
+namespace ReFlex.Core.Common.Util
+{
+ public static class LogUtilities
+ {
+ private static readonly Logger Log = LogManager.GetCurrentClassLogger();
+ private static readonly ConcurrentDictionary LoggedErrors = new ConcurrentDictionary();
+
+ ///
+ /// Logs an exception only once for the same source, method and exception signature.
+ ///
+ /// Exception to log.
+ /// Logical source (for example class name) used for scoping deduplication.
+ /// Method name used for scoping deduplication.
+ /// Optional custom message for the log entry.
+ /// Thrown when required arguments are null.
+ public static void LogErrorOnce(
+ Exception exception,
+ string sourceName,
+ string methodName,
+ string message = null)
+ {
+ if (sourceName == null)
+ throw new ArgumentNullException(nameof(sourceName));
+ if (methodName == null)
+ throw new ArgumentNullException(nameof(methodName));
+
+ if (exception == null)
+ {
+ return;
+ }
+
+ var errorKey = BuildErrorKey(sourceName, methodName, exception);
+ if (!LoggedErrors.TryAdd(errorKey, 0))
+ {
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(message))
+ {
+ Log.Error(exception);
+ return;
+ }
+
+ Log.Error(exception, message);
+ }
+
+ ///
+ /// Clears deduplication entries.
+ /// If is provided, only entries for this source are removed.
+ /// If it is null or whitespace, all entries are removed.
+ ///
+ /// Optional source name filter.
+ public static void ClearLoggedErrors(string sourceName = null)
+ {
+ if (string.IsNullOrWhiteSpace(sourceName))
+ {
+ LoggedErrors.Clear();
+ return;
+ }
+
+ var sourcePrefix = $"{sourceName}:";
+ var keys = LoggedErrors.Keys
+ .Where(key => key.StartsWith(sourcePrefix, StringComparison.Ordinal))
+ .ToList();
+
+ foreach (var key in keys)
+ {
+ LoggedErrors.TryRemove(key, out _);
+ }
+ }
+
+ ///
+ /// Creates a key that uniquely identifies an error context for deduplicated logging.
+ ///
+ /// Logical source (for example class name).
+ /// Method name where the exception occurred.
+ /// Exception to create the key from.
+ /// Deterministic key based on source, method and exception signature.
+ private static string BuildErrorKey(string sourceName, string methodName, Exception exception)
+ {
+ return $"{sourceName}:{methodName}:{BuildExceptionSignature(exception)}";
+ }
+
+ ///
+ /// Builds a deterministic signature from the exception chain.
+ /// This includes each exception type and message, including inner exceptions.
+ ///
+ /// Exception to convert to a signature.
+ /// Signature string that can be used for deduplication.
+ private static string BuildExceptionSignature(Exception exception)
+ {
+ var builder = new StringBuilder();
+ var current = exception;
+
+ while (current != null)
+ {
+ builder.Append(current.GetType().FullName);
+ builder.Append(':');
+ builder.Append(current.Message);
+ builder.Append('|');
+ current = current.InnerException;
+ }
+
+ return builder.ToString();
+ }
+ }
+}
diff --git a/library/src/Core/Tuio/Components/TuioSender.cs b/library/src/Core/Tuio/Components/TuioSender.cs
index 4732547c..c44eb689 100644
--- a/library/src/Core/Tuio/Components/TuioSender.cs
+++ b/library/src/Core/Tuio/Components/TuioSender.cs
@@ -10,6 +10,7 @@
using NLog;
using ReFlex.Core.Common.Adapter;
using ReFlex.Core.Common.Interfaces;
+using ReFlex.Core.Common.Util;
using ReFlex.Core.Tuio.Interfaces;
using ReFlex.Core.Tuio.Util;
@@ -23,12 +24,12 @@ public class TuioSender : ITuioSender
/// Client for sending TUIO Messages to Server using UDP
///
protected UdpClient UdpClient;
-
+
///
/// Client Interface for sending TUIO Messages to Server using TCP. (use for better test abilities)
///
protected ITcpClient TcpClient;
-
+
///
/// Client Interface for sending TUIO Messages to Server using WebSocket protocol. (use for better test abilities)
///
@@ -78,6 +79,7 @@ public virtual void Initialize(TuioConfiguration config)
_serverAddress = config.ServerAddress;
_serverPort = config.ServerPort;
+ LogUtilities.ClearLoggedErrors(nameof(TuioSender));
IsInitialized = true;
}
@@ -96,7 +98,7 @@ public virtual async Task SendUdp(OscBundle bundle)
}
catch (Exception exc)
{
- Log.Error(exc, $"Error sending osc message via UDP");
+ LogUtilities.LogErrorOnce(exc, nameof(TuioSender), nameof(SendUdp), "Error sending osc message via UDP");
}
}
@@ -115,7 +117,7 @@ public virtual async Task SendTcp(OscBundle bundle)
}
catch (Exception exc)
{
- Log.Error(exc);
+ LogUtilities.LogErrorOnce(exc, nameof(TuioSender), nameof(SendTcp));
}
}
@@ -146,7 +148,7 @@ public virtual async Task SendWebSocket(OscBundle bundle)
}
catch (Exception exc)
{
- Log.Error(exc);
+ LogUtilities.LogErrorOnce(exc, nameof(TuioSender), nameof(SendWebSocket));
}
}
@@ -165,7 +167,14 @@ public virtual async Task SendWebSocket(OscBundle bundle)
/// to be transmitted
protected virtual async Task SendOscMessageUdp(OscMessage msg)
{
- await UdpClient.SendMessageAsync(msg);
+ try
+ {
+ await UdpClient.SendMessageAsync(msg);
+ }
+ catch (Exception exc)
+ {
+ LogUtilities.LogErrorOnce(exc, nameof(TuioSender), nameof(SendOscMessageUdp));
+ }
}
///
@@ -190,7 +199,7 @@ protected virtual async Task SendOscMessageTcp(OscMessage msg)
}
catch (Exception exc)
{
- Log.Error(exc);
+ LogUtilities.LogErrorOnce(exc, nameof(TuioSender), nameof(SendOscMessageTcp));
}
}
@@ -218,7 +227,7 @@ await WsClient.SendAsync(new ArraySegment(mStream.ToArray()),
}
catch (Exception exc)
{
- Log.Error(exc);
+ LogUtilities.LogErrorOnce(exc, nameof(TuioSender), nameof(SendOscMessageWebSocket));
}
}
@@ -237,8 +246,9 @@ await WsClient.CloseAsync(WebSocketCloseStatus.NormalClosure, "Close connection"
UdpClient?.Dispose();
TcpClient?.Dispose();
WsClient?.Dispose();
+ LogUtilities.ClearLoggedErrors(nameof(TuioSender));
IsInitialized = false;
}
}
-}
\ No newline at end of file
+}
diff --git a/test/Library/Tuio.Test/LogUtilitiesTest.cs b/test/Library/Tuio.Test/LogUtilitiesTest.cs
new file mode 100644
index 00000000..2f9f40b7
--- /dev/null
+++ b/test/Library/Tuio.Test/LogUtilitiesTest.cs
@@ -0,0 +1,193 @@
+using System;
+using NLog;
+using NLog.Config;
+using NLog.Targets;
+using NUnit.Framework;
+using ReFlex.Core.Common.Util;
+
+namespace ReFlex.Core.Tuio.Test
+{
+ [TestFixture]
+ public class LogUtilitiesTest
+ {
+ private LoggingConfiguration _previousConfiguration;
+ private MemoryTarget _memoryTarget;
+
+ [SetUp]
+ public void Setup()
+ {
+ // Store current configuration to restore it after each test.
+ _previousConfiguration = LogManager.Configuration;
+
+ // Capture all log entries in memory so assertions can validate deduplication behavior.
+ _memoryTarget = new MemoryTarget("LogUtilitiesMemoryTarget")
+ {
+ Layout = "${level}|${message}|${exception:format=Type,Message}"
+ };
+
+ var config = new LoggingConfiguration();
+ config.AddRuleForAllLevels(_memoryTarget);
+ LogManager.Configuration = config;
+ LogManager.ReconfigExistingLoggers();
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ // Ensure static deduplication state does not leak into the next test.
+ LogUtilities.ClearLoggedErrors();
+
+ // Restore original logging configuration for the rest of the test suite.
+ LogManager.Configuration = _previousConfiguration;
+ LogManager.ReconfigExistingLoggers();
+
+ _memoryTarget?.Dispose();
+ }
+
+ [Test]
+ public void TestNoAdditionalLogWhenKeyExists()
+ {
+ // Use a unique source scope so tests stay isolated even with shared static cache.
+ var sourceName = CreateUniqueSourceName(nameof(TestNoAdditionalLogWhenKeyExists));
+ var methodName = "SendUdp";
+
+ // First occurrence should be logged.
+ LogUtilities.LogErrorOnce(
+ new InvalidOperationException("send failed"),
+ sourceName,
+ methodName);
+
+ // Same exception signature in same source/method should be suppressed.
+ LogUtilities.LogErrorOnce(
+ new InvalidOperationException("send failed"),
+ sourceName,
+ methodName);
+
+ // Exactly one log entry is expected.
+ Assert.That(_memoryTarget.Logs.Count, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void TestClearAllowsLoggingAgain()
+ {
+ // Unique source keeps this test independent from other runs.
+ var sourceName = CreateUniqueSourceName(nameof(TestClearAllowsLoggingAgain));
+ var methodName = "SendTcp";
+
+ // First log call should pass through.
+ LogUtilities.LogErrorOnce(
+ new InvalidOperationException("connection failed"),
+ sourceName,
+ methodName,
+ "custom message");
+
+ // Reset deduplication state for this source.
+ LogUtilities.ClearLoggedErrors(sourceName);
+
+ // After reset, same error must be logged again.
+ LogUtilities.LogErrorOnce(
+ new InvalidOperationException("connection failed"),
+ sourceName,
+ methodName,
+ "custom message");
+
+ // One log before and one log after clear.
+ Assert.That(_memoryTarget.Logs.Count, Is.EqualTo(2));
+ Assert.That(_memoryTarget.Logs[0], Does.Contain("custom message"));
+ Assert.That(_memoryTarget.Logs[1], Does.Contain("custom message"));
+ }
+
+ [Test]
+ public void TestDifferentMessagesAndExceptionTypesAreLoggedOnceEach()
+ {
+ // Unique source keeps this test independent from other runs.
+ var sourceName = CreateUniqueSourceName(nameof(TestDifferentMessagesAndExceptionTypesAreLoggedOnceEach));
+ var methodName = "SendOscMessageTcp";
+
+ // Distinct signatures: message, type and inner-exception chain.
+ LogUtilities.LogErrorOnce(
+ new InvalidOperationException("message A"),
+ sourceName,
+ methodName);
+
+ LogUtilities.LogErrorOnce(
+ new InvalidOperationException("message B"),
+ sourceName,
+ methodName);
+
+ LogUtilities.LogErrorOnce(
+ new ArgumentException("message A"),
+ sourceName,
+ methodName);
+
+ LogUtilities.LogErrorOnce(
+ new InvalidOperationException("message A", new Exception("inner A")),
+ sourceName,
+ methodName);
+
+ // Repeat all variants; duplicates should be suppressed.
+ LogUtilities.LogErrorOnce(
+ new InvalidOperationException("message A"),
+ sourceName,
+ methodName);
+
+ LogUtilities.LogErrorOnce(
+ new InvalidOperationException("message B"),
+ sourceName,
+ methodName);
+
+ LogUtilities.LogErrorOnce(
+ new ArgumentException("message A"),
+ sourceName,
+ methodName);
+
+ LogUtilities.LogErrorOnce(
+ new InvalidOperationException("message A", new Exception("inner A")),
+ sourceName,
+ methodName);
+
+ // Four unique signatures should produce four logs total.
+ Assert.That(_memoryTarget.Logs.Count, Is.EqualTo(4));
+ }
+
+ [Test]
+ public void TestSameErrorIsLoggedOncePerMethod()
+ {
+ // Unique source keeps this test independent from other runs.
+ var sourceName = CreateUniqueSourceName(nameof(TestSameErrorIsLoggedOncePerMethod));
+ var firstMethod = "SendTcp";
+ var secondMethod = "SendOscMessageTcp";
+
+ // Same error in first method: only first call logs.
+ LogUtilities.LogErrorOnce(
+ new InvalidOperationException("same error"),
+ sourceName,
+ firstMethod);
+
+ LogUtilities.LogErrorOnce(
+ new InvalidOperationException("same error"),
+ sourceName,
+ firstMethod);
+
+ // Same error in different method should log once again (method is part of the key).
+ LogUtilities.LogErrorOnce(
+ new InvalidOperationException("same error"),
+ sourceName,
+ secondMethod);
+
+ LogUtilities.LogErrorOnce(
+ new InvalidOperationException("same error"),
+ sourceName,
+ secondMethod);
+
+ // One log per method is expected.
+ Assert.That(_memoryTarget.Logs.Count, Is.EqualTo(2));
+ }
+
+ private static string CreateUniqueSourceName(string testName)
+ {
+ // A per-test unique source avoids cross-test interference through the static cache.
+ return $"{nameof(LogUtilitiesTest)}.{testName}.{Guid.NewGuid()}";
+ }
+ }
+}