From adda87dfe379a175e23532e63ce8971efead851a Mon Sep 17 00:00:00 2001 From: Joel Christner Date: Sun, 22 Mar 2026 20:55:20 -0700 Subject: [PATCH] Final commit --- .editorconfig | 171 ++ .github/workflows/build-and-test.yml | 56 + ARCHITECTURE.md | 499 +++++ CHANGELOG.md | 42 +- Directory.Build.props | 9 + README.md | 45 +- src/Test.Automated/Program.cs | 960 +++++++++- src/Test.XUnit/Test.XUnit.csproj | 32 + src/Test.XUnit/WatsonTcpTests.cs | 1653 +++++++++++++++++ src/Test.XUnit/xunit.runner.json | 4 + src/WatsonTcp.sln | 14 + src/WatsonTcp/ClientMetadata.cs | 15 +- src/WatsonTcp/ClientMetadataManager.cs | 1234 ++++++------ src/WatsonTcp/DefaultSerializationHelper.cs | 5 +- .../SyncResponseReceivedEventArgs.cs | 2 +- src/WatsonTcp/WatsonMessageBuilder.cs | 394 ++-- src/WatsonTcp/WatsonTcp.csproj | 7 +- src/WatsonTcp/WatsonTcp.xml | 27 + src/WatsonTcp/WatsonTcpClient.cs | 143 +- src/WatsonTcp/WatsonTcpClientEvents.cs | 2 +- src/WatsonTcp/WatsonTcpClientSettings.cs | 21 +- .../WatsonTcpClientSslConfiguration.cs | 2 +- src/WatsonTcp/WatsonTcpServer.cs | 160 +- src/WatsonTcp/WatsonTcpServerEvents.cs | 2 +- src/WatsonTcp/WatsonTcpServerSettings.cs | 26 + .../WatsonTcpServerSslConfiguration.cs | 2 +- 26 files changed, 4441 insertions(+), 1086 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/build-and-test.yml create mode 100644 ARCHITECTURE.md create mode 100644 Directory.Build.props create mode 100644 src/Test.XUnit/Test.XUnit.csproj create mode 100644 src/Test.XUnit/WatsonTcpTests.cs create mode 100644 src/Test.XUnit/xunit.runner.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..59bd689 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,171 @@ +# EditorConfig - https://editorconfig.org +root = true + +# All files +[*] +indent_style = space +indent_size = 4 +end_of_line = crlf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +# XML project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj,props,targets}] +indent_size = 2 + +# XML config files +[*.{xml,config,nuspec,resx,ruleset,vsixmanifest,vsct}] +indent_size = 2 + +# JSON files +[*.json] +indent_size = 2 + +# YAML files +[*.{yml,yaml}] +indent_size = 2 + +# Markdown files +[*.md] +trim_trailing_whitespace = false + +# C# files +[*.cs] +# Organize usings +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +# Namespace preferences +csharp_style_namespace_declarations = file_scoped:suggestion + +# var preferences +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = false:suggestion +csharp_style_expression_bodied_constructors = false:suggestion +csharp_style_expression_bodied_operators = false:suggestion +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_indexers = true:suggestion +csharp_style_expression_bodied_accessors = true:suggestion + +# Pattern matching preferences +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion + +# Null-checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true + +# Indentation preferences +csharp_indent_case_contents = true +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_method_call_parameter_list_parentheses = false + +# Wrapping preferences +csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true + +# Naming conventions +dotnet_naming_rule.interface_should_be_begins_with_i.severity = warning +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +# Analyzer severity overrides + +# --- Suppressed: not applicable across all target frameworks --- +# CA1510: Use ArgumentNullException.ThrowIfNull (.NET 6+ only, library targets netstandard2.0) +dotnet_diagnostic.CA1510.severity = none +# CA1512: Use ArgumentOutOfRangeException.ThrowIfNegativeOrZero (.NET 8+ only) +dotnet_diagnostic.CA1512.severity = none +# CA1835: Use Memory-based ReadAsync/WriteAsync (.NET Standard doesn't have these overloads) +dotnet_diagnostic.CA1835.severity = none + +# --- Suppressed: intentional design choices --- +# CA1711: Do not suffix type names with 'EventArgs' - these ARE EventArgs subclasses +dotnet_diagnostic.CA1711.severity = none +# CA1720: Identifier contains type name - 'guid' is the domain term for client identifiers +dotnet_diagnostic.CA1720.severity = none +# CA1805: Do not explicitly initialize to default - intentional for readability in this codebase +dotnet_diagnostic.CA1805.severity = none +# CA5359: Do not disable certificate validation - AcceptInvalidCertificates is a documented user choice +dotnet_diagnostic.CA5359.severity = none + +# --- Suppressed: pre-existing patterns, low risk --- +# CA1031: Do not catch general exception types +dotnet_diagnostic.CA1031.severity = none +# CA1032: Implement standard exception constructors +dotnet_diagnostic.CA1032.severity = none +# CA1051: Do not declare visible instance fields - internal fields on internal types +dotnet_diagnostic.CA1051.severity = none +# CA1062: Validate arguments of public methods +dotnet_diagnostic.CA1062.severity = none +# CA1303: Do not pass literals as localized parameters +dotnet_diagnostic.CA1303.severity = none +# CA1304: Specify CultureInfo +dotnet_diagnostic.CA1304.severity = none +# CA1311: Specify a culture or use an invariant version +dotnet_diagnostic.CA1311.severity = none +# CA1725: Parameter names should match base declaration +dotnet_diagnostic.CA1725.severity = none +# CA1850: Prefer static HashData method over ComputeHash +dotnet_diagnostic.CA1850.severity = none +# CA1854: Prefer TryGetValue over ContainsKey+indexer +dotnet_diagnostic.CA1854.severity = none +# CA1859: Change type for improved performance +dotnet_diagnostic.CA1859.severity = none +# CA1872: Prefer Convert.ToHexString over BitConverter.ToString chains +dotnet_diagnostic.CA1872.severity = none +# CA2201: Do not raise reserved exception types +dotnet_diagnostic.CA2201.severity = none +# CA5351: Do not use broken cryptographic algorithms (MD5 in test projects) +dotnet_diagnostic.CA5351.severity = none +# CA2263: Prefer generic overload of Enum.Parse - not available on all target frameworks +dotnet_diagnostic.CA2263.severity = none + +# --- Suppressed globally, enforced in library csproj via AnalysisLevel --- +# CA1305: Specify IFormatProvider +dotnet_diagnostic.CA1305.severity = none +# CA1309: Use ordinal string comparison +dotnet_diagnostic.CA1309.severity = none +# CA1816: Call GC.SuppressFinalize correctly +dotnet_diagnostic.CA1816.severity = none +# CA1822: Member can be static +dotnet_diagnostic.CA1822.severity = none +# CA1852: Type can be sealed +dotnet_diagnostic.CA1852.severity = none +# CA2016: Forward CancellationToken +dotnet_diagnostic.CA2016.severity = none +# CA2208: Instantiate argument exceptions correctly +dotnet_diagnostic.CA2208.severity = none +# CA2007: Do not directly await a Task without ConfigureAwait +dotnet_diagnostic.CA2007.severity = none +# CA2227: Collection properties should be read only +dotnet_diagnostic.CA2227.severity = none + +# IDE0005: Using directive is unnecessary +dotnet_diagnostic.IDE0005.severity = none +# IDE0044: Add readonly modifier +dotnet_diagnostic.IDE0044.severity = none +# IDE0060: Remove unused parameter +dotnet_diagnostic.IDE0060.severity = none diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..f98ff91 --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,56 @@ +name: Build and Test + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + build-and-test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Restore dependencies + run: dotnet restore src/WatsonTcp/WatsonTcp.csproj + + - name: Restore test dependencies + run: dotnet restore src/Test.XUnit/Test.XUnit.csproj + + - name: Build library (Release) + run: dotnet build src/WatsonTcp/WatsonTcp.csproj --configuration Release --no-restore + + - name: Build tests (Release) + run: dotnet build src/Test.XUnit/Test.XUnit.csproj --configuration Release --no-restore + + - name: Run tests (Linux - net8.0 and net10.0 only) + if: runner.os == 'Linux' + run: | + dotnet test src/Test.XUnit/Test.XUnit.csproj --configuration Release --no-build --framework net8.0 --logger "trx;LogFileName=test-results-net8.0.trx" --results-directory ./test-results + dotnet test src/Test.XUnit/Test.XUnit.csproj --configuration Release --no-build --framework net10.0 --logger "trx;LogFileName=test-results-net10.0.trx" --results-directory ./test-results + + - name: Run tests (Windows - all frameworks) + if: runner.os == 'Windows' + run: | + dotnet test src/Test.XUnit/Test.XUnit.csproj --configuration Release --no-build --logger "trx;LogFileName=test-results.trx" --results-directory ./test-results + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.os }} + path: ./test-results/*.trx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..2253ae0 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,499 @@ +# WatsonTcp Internal Architecture + +## 1. Overview + +WatsonTcp is a C# TCP client/server library that provides reliable message-level delivery over TCP by implementing a custom framing protocol. TCP is a bidirectional byte stream with no inherent message boundaries; WatsonTcp solves this by prepending each message with a JSON header that declares the payload length, followed by a `\r\n\r\n` delimiter, followed by the raw data bytes. This allows receivers to know exactly how many bytes constitute each application-level message. + +The library targets .NET Standard 2.0/2.1, .NET Framework 4.62/4.8, and .NET 6.0/8.0. Both endpoints of a connection must either use WatsonTcp or implement compatible framing (see [FRAMING.md](FRAMING.md) for the wire protocol specification). + +Beyond framing, WatsonTcp provides: +- Automatic connection lifecycle management with event-driven notifications +- Synchronous request/response messaging (with timeout and expiration) +- Optional SSL/TLS encryption +- Preshared key authentication +- Idle connection detection and timeout +- TCP keepalive configuration + +## 2. Message Flow + +### Sending a Message + +When a caller invokes `SendAsync()`, the following sequence occurs: + +``` +SendAsync(data, metadata) + | + v +BytesToStream(data) --> convert byte[] to MemoryStream + contentLength + | + v +MessageBuilder.ConstructNew(contentLength, stream, metadata, ...) + | --> creates WatsonMessage with fields populated + v +SendInternalAsync(msg, contentLength, stream, token) + | + v +WriteLock.WaitAsync() --> acquire per-connection SemaphoreSlim(1,1) + | + v +SendHeadersAsync(msg) + | MessageBuilder.GetHeaderBytes(msg) + | --> SerializeJson(msg) --> UTF-8 encode --> append \r\n\r\n + | DataStream.WriteAsync(headerBytes) + | DataStream.FlushAsync() + v +SendDataStreamAsync(contentLength, stream) + | Rent buffer from ArrayPool.Shared + | Loop: ReadAsync from source stream, WriteAsync to DataStream + | FlushAsync() + | Return buffer to ArrayPool + v +WriteLock.Release() +``` + +### Wire Format + +Each message on the wire has this structure: + +``` ++--------------------------------------------------+ +| JSON header (UTF-8 encoded, no pretty printing) | +| {"len":N,"status":"Normal","syncreq":false,...} | ++--------------------------------------------------+ +| \r\n\r\n (bytes: 13, 10, 13, 10) | ++--------------------------------------------------+ +| Raw data bytes (exactly N bytes) | ++--------------------------------------------------+ +``` + +See [FRAMING.md](FRAMING.md) for the complete wire protocol specification and guidance on integrating non-WatsonTcp endpoints. + +### Receiving a Message + +The receiving side runs a `DataReceiver` background task that loops continuously: + +``` +DataReceiver loop: + | + v +MessageBuilder.BuildFromStream(dataStream) + | Read one byte at a time into MemoryStream accumulator + | Track last 4 bytes in a sliding window + | When \r\n\r\n detected: deserialize header JSON --> WatsonMessage + | Set msg.DataStream = the underlying TCP/SSL stream + v +Process by MessageStatus: + | + +-- AuthRequired/AuthSuccess/AuthFailure --> authentication flow + +-- Shutdown/Removed/Timeout --> disconnect + +-- RegisterClient --> GUID registration + +-- Normal + SyncRequest --> invoke SyncRequestReceived callback, send response + +-- Normal + SyncResponse --> resolve matching TaskCompletionSource + +-- Normal --> read data, fire MessageReceived or StreamReceived event +``` + +### Stream vs. Byte Array Delivery + +When `Events.MessageReceived` is set, the data receiver reads the full payload into a `byte[]` via `WatsonCommon.ReadMessageDataAsync()` before firing the event. When `Events.StreamReceived` is set instead, behavior depends on message size: + +- **Large messages** (ContentLength >= `Settings.MaxProxiedStreamSize`): A `WatsonStream` wrapping the raw TCP/SSL stream is delivered synchronously. The event handler must consume the stream before returning, as the underlying connection stream advances. +- **Small messages**: Data is first copied into a `MemoryStream`, then wrapped in a `WatsonStream` and delivered asynchronously via `Task.Run`. + +## 3. Component Diagram + +``` ++----------------------------------------------------------+ +| WatsonTcpClient | +| Settings, Events, Callbacks, Keepalive, SslConfiguration | +| _WriteLock (SemaphoreSlim) | +| _ReadLock (SemaphoreSlim) | +| _DataReceiver (Task) | +| _IdleServerMonitor (Task) | +| _SyncRequests (ConcurrentDictionary) | +| _DataStream (NetworkStream or SslStream) | ++---------------------------+------------------------------+ + | + | uses + v ++----------------------------------------------------------+ +| WatsonMessageBuilder | +| ConstructNew() --> WatsonMessage | +| BuildFromStream() --> WatsonMessage (from wire) | +| GetHeaderBytes() --> byte[] (JSON + \r\n\r\n) | +| SerializationHelper, ReadStreamBuffer, MaxHeaderSize | ++---------------------------+------------------------------+ + | + | creates / parses + v ++----------------------------------------------------------+ +| WatsonMessage | +| ContentLength, Status, Metadata, SyncRequest, | +| SyncResponse, ConversationGuid, ExpirationUtc, | +| TimestampUtc, PresharedKey, SenderGuid, DataStream | ++----------------------------------------------------------+ + ++----------------------------------------------------------+ +| WatsonTcpServer | +| Settings, Events, Callbacks, Keepalive, SslConfiguration | +| _AcceptConnections (Task) | +| _MonitorClients (Task) | +| _SyncRequests (ConcurrentDictionary) | +| _Listener (TcpListener) | ++---------------------------+------------------------------+ + | + | manages clients via + v ++----------------------------------------------------------+ +| ClientMetadataManager | +| _Lock (ReaderWriterLockSlim) | +| _Clients (Dictionary) | +| _UnauthenticatedClients (Dictionary) | +| _ClientsLastSeen (Dictionary) | +| _ClientsKicked (Dictionary) | +| _ClientsTimedout (Dictionary) | ++---------------------------+------------------------------+ + | + | stores + v ++----------------------------------------------------------+ +| ClientMetadata | +| Guid, IpPort, Name, Metadata (user-defined) | +| TcpClient, NetworkStream, SslStream, DataStream | +| WriteLock (SemaphoreSlim), ReadLock (SemaphoreSlim) | +| TokenSource / Token (CancellationToken) | +| DataReceiver (Task) | +| SendBuffer (byte[]) | ++----------------------------------------------------------+ + ++----------------------------------------------------------+ +| WatsonStream | +| Read-only Stream wrapper with length tracking | +| Wraps the underlying TCP/SSL stream or a MemoryStream | +| Tracks _Position and _BytesRemaining | +| CanRead=true, CanSeek=false, CanWrite=false | ++----------------------------------------------------------+ + ++----------------------------------------------------------+ +| WatsonCommon | +| DataStreamToMemoryStream() - buffered stream copy | +| ReadFromStreamAsync() - read N bytes from stream | +| ReadMessageDataAsync() - read message payload | +| BytesToStream() - byte[] to Stream + contentLength | +| GetExpirationTimestamp() - clock-skew adjusted expiration| +| ByteArrayToHex(), AppendBytes() - utilities | ++----------------------------------------------------------+ +``` + +## 4. Client Architecture + +### Connection Lifecycle + +``` +new WatsonTcpClient(ip, port) // or SSL constructor variant + | + v +Connect() / ConnectAsync() + | + +-- Create TcpClient, set NoDelay + +-- BeginConnect with timeout (ConnectTimeoutSeconds) + +-- TCP mode: get NetworkStream as _DataStream + +-- SSL mode: wrap in SslStream, AuthenticateAsClient, set _DataStream = _SslStream + +-- Enable TCP keepalives if configured + +-- Send RegisterClient message (MessageStatus.RegisterClient) + +-- Start _DataReceiver task (DataReceiver loop) + +-- Start _IdleServerMonitor task + +-- Fire ServerConnected event + | + v +[Connected state - DataReceiver loop running] + | + v +Disconnect() / Dispose() + +-- Send Shutdown message (optional) + +-- Cancel TokenSource + +-- Close SslStream, NetworkStream, TcpClient + +-- Wait for DataReceiver and IdleServerMonitor tasks (up to 5s) + +-- Set Connected = false +``` + +### Send Pipeline + +Every send operation follows this pattern: + +1. Acquire `_WriteLock` (SemaphoreSlim(1,1)) -- serializes all writes on the connection +2. Serialize the `WatsonMessage` to JSON, append `\r\n\r\n`, write header bytes to `_DataStream` +3. Flush the stream +4. Read from the source data stream in chunks using a buffer rented from `ArrayPool.Shared`, write each chunk to `_DataStream` +5. Flush the stream +6. Release `_WriteLock` + +If a write fails with an exception, the client sets `Connected = false` and calls `Dispose()`. + +### Synchronous Request/Response + +The sync request/response mechanism uses `ConcurrentDictionary>`: + +1. **Sender** creates a `TaskCompletionSource` and stores it in `_SyncRequests` keyed by the message's `ConversationGuid` +2. **Sender** sends the message with `SyncRequest = true` and an `ExpirationUtc` +3. **Sender** awaits `tcs.Task` with a timeout via linked `CancellationTokenSource` +4. **Receiver's DataReceiver** detects `msg.SyncRequest == true`, invokes the `SyncRequestReceived` callback, and sends back a response with `SyncResponse = true` and the same `ConversationGuid` +5. **Original sender's DataReceiver** detects `msg.SyncResponse == true`, looks up the `ConversationGuid` in `_SyncRequests`, and calls `tcs.TrySetResult()` to unblock the awaiting caller +6. If the timeout fires before a response arrives, the `CancellationTokenSource` cancels the TCS and a `TimeoutException` is thrown + +Clock skew between sender and receiver is handled by `WatsonCommon.GetExpirationTimestamp()`, which adjusts the expiration time based on the difference between `DateTime.UtcNow` and `msg.TimestampUtc`. + +## 5. Server Architecture + +### Listener Lifecycle + +``` +new WatsonTcpServer(ip, port) // or SSL constructor variant + | + v +Start() + +-- Create TcpListener, start listening + +-- Start _AcceptConnections task + +-- Start _MonitorClients task (idle client monitoring) + +-- Fire ServerStarted event + | + v +AcceptConnections loop: + +-- Check MaxConnections (pause listener if at capacity with enforcement) + +-- AcceptTcpClientAsync() + +-- Validate against PermittedIPs / BlockedIPs + +-- Create ClientMetadata from TcpClient + +-- Add to ClientMetadataManager + +-- Create linked CancellationToken (server token + client token) + +-- TCP mode: Task.Run(FinalizeConnection) + +-- SSL mode: Task.Run(StartTls then FinalizeConnection) + | + v +FinalizeConnection(client): + +-- If PresharedKey configured: send AuthRequired, add to unauthenticated list + +-- Start client.DataReceiver = Task.Run(DataReceiver) + | + v +DataReceiver(client) loop: + +-- Check client connectivity (IsClientConnected) + +-- BuildFromStream() to read next message + +-- Handle authentication if client is unauthenticated + +-- Handle RegisterClient (GUID replacement) + +-- Handle sync request/response, normal messages, shutdown + +-- Update ClientsLastSeen timestamp + | + v (on exit from DataReceiver) + +-- Determine DisconnectReason (Removed, Timeout, Normal) + +-- Fire ClientDisconnected event + +-- Remove from ClientMetadataManager + +-- Decrement _Connections counter + +-- Dispose ClientMetadata +``` + +### Client Management + +`ClientMetadataManager` maintains five parallel dictionaries, all protected by a single `ReaderWriterLockSlim`: + +| Dictionary | Key | Value | Purpose | +|---|---|---|---| +| `_Clients` | `Guid` | `ClientMetadata` | Active client connections | +| `_UnauthenticatedClients` | `Guid` | `DateTime` | Clients awaiting PSK authentication | +| `_ClientsLastSeen` | `Guid` | `DateTime` | Timestamp of last message from each client | +| `_ClientsKicked` | `Guid` | `DateTime` | Clients disconnected by server (kicked) | +| `_ClientsTimedout` | `Guid` | `DateTime` | Clients disconnected due to idle timeout | + +The kicked and timed-out dictionaries are used to determine the `DisconnectReason` when the `DataReceiver` loop exits. They are periodically purged by `PurgeStaleRecords()` (every ~60 seconds, records older than 5 minutes). + +### Idle Monitoring + +The `MonitorForIdleClients` task runs every 5 seconds: + +1. If `IdleClientTimeoutSeconds > 0`, iterates all clients' last-seen timestamps +2. Clients whose last activity exceeds the threshold are marked as timed out and disconnected +3. Every 12 iterations (~60 seconds), calls `PurgeStaleRecords()` to clean up stale kicked/timed-out records + +### Connection Count Management + +The server tracks active connections with `Interlocked.Increment/Decrement` on `_Connections`. When `MaxConnections` is reached: +- If `EnforceMaxConnections` is true: the listener is stopped and restarted when connections drop below the limit +- If enforcement is disabled: connections are accepted with a warning log + +## 6. Threading Model + +### Client Threads + +| Thread/Task | Purpose | Lifetime | +|---|---|---| +| Caller thread | `Connect()`, `SendAsync()`, `Disconnect()` | User-controlled | +| `_DataReceiver` | Background task reading from the TCP stream | Connect to Disconnect | +| `_IdleServerMonitor` | Polls for server idle timeout | Connect to Disconnect | +| Event handler tasks | `Task.Run()` for async event delivery | Per-message | + +### Server Threads + +| Thread/Task | Purpose | Lifetime | +|---|---|---| +| Caller thread | `Start()`, `Stop()`, `SendAsync()` | User-controlled | +| `_AcceptConnections` | Accepts incoming TCP connections | Start to Stop | +| `_MonitorClients` | Checks for idle clients every 5 seconds | Start to Stop | +| Per-client `DataReceiver` | One background task per connected client | Client connect to disconnect | +| Event handler tasks | `Task.Run()` for async event delivery | Per-message | +| SSL handshake tasks | `Task.Run()` for TLS negotiation per client | Brief, during connection setup | + +### Locks + +**`WriteLock` (SemaphoreSlim(1,1))** -- One per connection. On the client, `_WriteLock` is a field of `WatsonTcpClient`. On the server, each `ClientMetadata` has its own `WriteLock`. This serializes writes on each connection to prevent interleaving of header and data bytes from concurrent senders. The lock is held for the duration of both header and data transmission. + +**`ReadLock` (SemaphoreSlim(1,1))** -- Present on both client and `ClientMetadata`. On the client side, the `DataReceiver` acquires `_ReadLock` before calling `BuildFromStream()`. This exists to prevent concurrent reads, though in practice only the single `DataReceiver` task reads from each connection. + +**`ReaderWriterLockSlim` in `ClientMetadataManager`** -- A single lock protecting all five client dictionaries. Read operations (exists, get, count, list) acquire a read lock. Write operations (add, remove, update, replace, purge) acquire a write lock. This allows concurrent read access while serializing mutations. A single lock is used rather than per-dictionary locks to ensure atomicity of operations that touch multiple dictionaries (e.g., `Remove()` removes from all five, `ReplaceGuid()` updates all five). + +### Cancellation Token Flow + +Each `WatsonTcpClient` and `WatsonTcpServer` has a `CancellationTokenSource` (`_TokenSource`) created at startup. The token is passed to all background tasks. On the server, each `ClientMetadata` also has its own `CancellationTokenSource`, and a linked token source (`CancellationTokenSource.CreateLinkedTokenSource`) is created combining the server's token with the client's token. This means cancelling either the server or the individual client will stop the client's `DataReceiver`. + +Disposal triggers cancellation: `Disconnect()` cancels `_TokenSource`, and `ClientMetadata.Dispose()` cancels its own `TokenSource`. + +## 7. SSL/TLS + +SSL/TLS is implemented by layering `SslStream` on top of `NetworkStream`: + +### Client SSL + +``` +TcpClient.GetStream() --> NetworkStream + | + v +new SslStream(NetworkStream, leaveInnerStreamOpen: false, validationCallback) + | + v +SslStream.AuthenticateAsClient(serverHostname, clientCerts, sslProtocols, checkRevocation) + | + v +_DataStream = _SslStream // all reads/writes go through SslStream +``` + +When `Settings.AcceptInvalidCertificates` is true, a custom `ServerCertificateValidationCallback` from `SslConfiguration` is used (which accepts all certificates). When false, the default validation applies. + +### Server SSL + +``` +ClientMetadata.NetworkStream (from TcpClient.GetStream()) + | + v +new SslStream(NetworkStream, leaveInnerStreamOpen: false, validationCallback) + | + v +SslStream.AuthenticateAsServerAsync(serverCert, clientCertRequired, sslProtocols, checkRevocation) + | + v +client.DataStream = client.SslStream // automatically set by the SslStream property setter +``` + +The server performs TLS negotiation in a separate `Task.Run()` via `StartTls()`. If negotiation fails (stream not encrypted, not authenticated, or mutual auth fails), the client is disposed and the connection count decremented. + +Both client and server support mutual authentication via `Settings.MutuallyAuthenticate`. The TLS version is configurable (defaults to TLS 1.2). + +## 8. Authentication + +WatsonTcp supports optional preshared key (PSK) authentication. The flow: + +``` +Server Client + | | + | [client connects] | + | | + |-- AuthRequired ---------------------->| + | (MessageStatus.AuthRequired) | + | |-- check Settings.PresharedKey + | | or invoke Callbacks.AuthenticationRequested + |<-- AuthRequested --------------------| + | (MessageStatus.AuthRequested, | + | PresharedKey = 16-byte key) | + | | + |-- [compare PSK] | + | | + | [match] | + |-- AuthSuccess ----------------------->| + | (MessageStatus.AuthSuccess) |-- fire AuthenticationSucceeded event + | fire AuthenticationSucceeded event |-- send RegisterClient message + | | + | [no match] | + |-- AuthFailure ----------------------->| + | (MessageStatus.AuthFailure) |-- fire AuthenticationFailed event + | fire AuthenticationFailed event |-- disconnect + | disconnect client | +``` + +Key details: +- The preshared key must be exactly 16 bytes +- While unauthenticated, the server adds the client to `_UnauthenticatedClients` and ignores any non-auth messages +- On authentication success, the server removes the client from `_UnauthenticatedClients` +- The client re-sends the `RegisterClient` message after successful authentication, since the initial one sent during `Connect()` was ignored while unauthenticated + +## 9. Configuration + +### Settings Classes + +**`WatsonTcpClientSettings`**: `Guid` (client identifier), `ConnectTimeoutSeconds`, `IdleServerTimeoutMs`, `IdleServerEvaluationIntervalMs`, `StreamBufferSize`, `MaxProxiedStreamSize`, `MaxHeaderSize`, `NoDelay`, `DebugMessages`, `Logger`, `LocalPort`, `PresharedKey`, `AcceptInvalidCertificates`, `MutuallyAuthenticate` + +**`WatsonTcpServerSettings`**: `MaxConnections`, `EnforceMaxConnections`, `IdleClientTimeoutSeconds`, `StreamBufferSize`, `MaxProxiedStreamSize`, `MaxHeaderSize`, `NoDelay`, `DebugMessages`, `Logger`, `PermittedIPs`, `BlockedIPs`, `PresharedKey`, `AcceptInvalidCertificates`, `MutuallyAuthenticate` + +### Events Classes + +**`WatsonTcpClientEvents`**: `ServerConnected`, `ServerDisconnected`, `MessageReceived`, `StreamReceived`, `ExceptionEncountered`, `AuthenticationSucceeded`, `AuthenticationFailure` + +**`WatsonTcpServerEvents`**: `ClientConnected`, `ClientDisconnected`, `MessageReceived`, `StreamReceived`, `ExceptionEncountered`, `ServerStarted`, `ServerStopped`, `AuthenticationSucceeded`, `AuthenticationFailed` + +Only one of `MessageReceived` or `StreamReceived` should be set. `MessageReceived` takes precedence if both are set. + +### Callbacks Classes + +**`WatsonTcpClientCallbacks`**: `AuthenticationRequested` (returns PSK string), `SyncRequestReceived` / `SyncRequestReceivedAsync` (handles sync requests from the server) + +**`WatsonTcpServerCallbacks`**: `SyncRequestReceived` / `SyncRequestReceivedAsync` (handles sync requests from clients) + +### Keepalive Settings + +**`WatsonTcpKeepaliveSettings`**: `EnableTcpKeepAlives`, `TcpKeepAliveTime`, `TcpKeepAliveInterval`, `TcpKeepAliveRetryCount` + +TCP keepalives are configured at the socket level. On .NET 6.0+, socket options are set directly. On .NET Framework, `IOControl` with `KeepAliveValues` is used. Keepalives are not available on .NET Standard. + +### SSL Configuration + +**`WatsonTcpClientSslConfiguration`**: `ServerCertificateValidationCallback`, `ClientCertificateSelectionCallback` + +**`WatsonTcpServerSslConfiguration`**: `ClientCertificateValidationCallback`, `ClientCertificateRequired` + +## 10. Key Design Decisions + +### Byte-by-Byte Header Reading + +`WatsonMessageBuilder.BuildFromStream()` reads the header one byte at a time, tracking the last four bytes in a sliding window to detect the `\r\n\r\n` delimiter. This is deliberate: `NetworkStream` (and `SslStream`) do not support seeking. If a buffered read were used, the read could consume bytes beyond the header delimiter and into the message body. Since the header terminates at `\r\n\r\n` and the body length is only known after parsing the header, byte-by-byte reading is the safest approach to avoid over-reading. The implementation uses a `MemoryStream` accumulator rather than array concatenation to avoid O(n^2) allocation overhead. + +### ArrayPool for Send Buffers + +`SendDataStreamAsync()` rents buffers from `ArrayPool.Shared` rather than allocating new arrays for each send. This reduces GC pressure during high-throughput scenarios, as send buffers are frequently allocated and released. The buffer is rented at `StreamBufferSize` (default 65536 bytes) and returned in a `finally` block to ensure no leaks. + +### Single Lock in ClientMetadataManager + +All five dictionaries in `ClientMetadataManager` are protected by a single `ReaderWriterLockSlim` rather than individual locks. This is a deliberate trade-off: + +- **Atomicity**: Operations like `Remove(guid)` must remove entries from all five dictionaries atomically. With separate locks, partial removal could leave inconsistent state. +- **Simplicity**: `ReplaceGuid()` (used during client GUID registration) must update entries across all dictionaries in a single atomic operation. +- **Acceptable contention**: The lock is held for short durations (dictionary lookups/mutations), and `ReaderWriterLockSlim` allows concurrent reads. The server's message processing is per-client in separate tasks, so the manager lock is only contended during client lifecycle events (connect, disconnect, timeout checks), not during per-message processing. + +### WriteLock Per Connection + +Each connection has its own `SemaphoreSlim(1,1)` for write serialization. On the client, this is `_WriteLock` on the `WatsonTcpClient` instance. On the server, each `ClientMetadata` has its own `WriteLock`. This design ensures: + +- Header and data bytes for a single message are never interleaved with another message's bytes +- Multiple threads can send to different clients concurrently (server-side) without contention +- The semaphore is async-compatible (`WaitAsync`), avoiding thread pool starvation + +### Message Status as Control Plane + +`WatsonMessage.Status` serves as an in-band control plane. The `MessageStatus` enum includes values like `Normal`, `Shutdown`, `Removed`, `Timeout`, `AuthRequired`, `AuthRequested`, `AuthSuccess`, `AuthFailure`, and `RegisterClient`. This allows connection lifecycle management (authentication, registration, graceful shutdown) to use the same framing protocol as data messages, avoiding the need for a separate control channel. + +### WatsonStream as Bounded Stream Wrapper + +`WatsonStream` wraps the raw TCP/SSL stream but tracks `_Position` and `_BytesRemaining` based on the declared `ContentLength`. This prevents the consumer from reading beyond the current message's data into the next message's header. The stream is read-only, non-seekable, and non-writable, reflecting its role as a view over a specific segment of the underlying transport stream. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9130a76..4524919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,54 @@ ## Current Version +v6.1.0 + +### Performance + +- **Header parsing rewrite** - The old ```BuildFromStream``` read one byte at a time, allocated a new array on every byte via ```AppendBytes```, and ran LINQ ```.Skip().Take().ToArray()``` on every iteration to check for the ```\r\n\r\n``` delimiter. For a header of length H, that produced H-24 array allocations plus H-24 LINQ allocations. The new version writes into a ```MemoryStream``` and tracks the last 4 bytes with simple variables. Same byte-by-byte read (necessary to avoid over-reading past the delimiter into message body data on non-seekable ```NetworkStream```s), but zero per-byte allocations. +- **Buffer pooling** - Both client and server ```SendDataStreamAsync``` methods were allocating ```new byte[bufferSize]``` on every loop iteration of every send. Now they rent from ```ArrayPool.Shared``` once per send and return when done. ```GetHeaderBytes``` also replaced ```AppendBytes``` with ```Buffer.BlockCopy```. + +### Thread Safety + +- **ClientMetadataManager consolidated** - Had 5 separate ```ReaderWriterLockSlim``` instances protecting 5 dictionaries that represent one logical unit of client state. Operations like ```ReplaceGuid``` and ```Remove``` acquired/released each lock individually, creating windows where a client existed in one dictionary but not another. Now uses a single lock; all mutations are atomic. Also fixed ```GetClient``` which did ```ContainsKey``` then ```[guid]``` across two separate lock acquisitions (TOCTOU race leading to ```KeyNotFoundException```). Now uses ```TryGetValue```. +- **Sync response matching** - Both client and server used ```AutoResetEvent``` + multicast event handler subscription (```_SyncResponseReceived += handler```) with a ```lock``` around invocation. Race conditions existed between handler registration and message send, and between concurrent sync requests. Replaced with ```ConcurrentDictionary>```: register before sending, ```DataReceiver``` does ```TryRemove``` + ```TrySetResult``` on response arrival. Cleaner, lock-free, no signal loss. + +### Bug Fixes + +- **WaitHandle leak** - ```WatsonTcpClient.Connect()``` called ```BeginConnect```, got a ```WaitHandle```, but never closed it (commented out with a link to an MSDN forum post). Now closed in ```finally```. +- **Busy-wait spin loops** - ```ClientMetadata.Dispose()``` had ```while (DataReceiver?.Status == Running) Task.Delay(30).Wait()``` which blocks a thread pool thread spinning. Same pattern in ```WatsonTcpClient.Disconnect()``` for both ```_DataReceiver``` and ```_IdleServerMonitor```. Replaced with ```Task.Wait(TimeSpan.FromSeconds(5))```. +- **Stale client records** - ```_ClientsKicked``` and ```_ClientsTimedout``` dictionaries accumulated entries forever. Added ```PurgeStaleRecords(TimeSpan)``` to ```ClientMetadataManager```, called every ~60 seconds from ```MonitorForIdleClients```, purging records older than 5 minutes that don't correspond to active clients. + +### New Features + +- **```Settings.MaxHeaderSize```** (client and server, default 262144/256KB) - The old header parser had no upper bound. A malformed or malicious peer could send megabytes without a ```\r\n\r\n``` delimiter and the server would allocate until OOM. Now throws ```IOException``` when exceeded. +- **```Settings.EnforceMaxConnections```** (server, default ```true```) - Previously, ```MaxConnections``` only paused the listener (stopped accepting) but the check happened after the connection was already accepted, so it was really just a soft warning. Now, when enforcement is on, connections are actively rejected with ```tcpClient.Close()``` before any client state is created. When off, the old behavior is preserved (accept anyway, log a warning, don't stop the listener). + +### Observability + +- Added ```Severity.Debug``` logging to all previously silent ```catch (TaskCanceledException) { }``` and ```catch (OperationCanceledException) { }``` blocks in ```IdleServerMonitor```, ```MonitorForIdleClients```, and ```SendInternalAsync``` on both client and server. + +### Testing + +- 10 new automated tests (46 total): MaxConnections enforcement (happy + sad), MaxHeaderSize validation, rapid connect/disconnect (10 cycles), concurrent sync requests (5 parallel ```SendAndWaitAsync``` with response verification), SSL connectivity + message exchange, server stop detection from client, duplicate client GUID handling, and send-with-byte-offset. + +### Breaking Changes + +- ```Settings.EnforceMaxConnections``` defaults to ```true```. If you were relying on the server accepting unlimited connections despite ```MaxConnections``` being set, connections will now be rejected at capacity. Set ```EnforceMaxConnections = false``` to restore the old behavior. +- All other changes are internal implementation details with identical public API signatures and wire protocol. + +## Previous Versions + v6.0.x - Remove unsupported frameworks - Async version of ```SyncMessageReceived``` callback - Moving usings inside namespace - Remove obsolete methods -- Mark non-async APIs obsolete +- Mark non-async APIs obsolete - Modified test projects to use async - Ensured background tasks honored cancellation tokens -## Previous Versions - v5.1.x - Strong name signing diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..c1965ea --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,9 @@ + + + + + true + true + + + diff --git a/README.md b/README.md index 3be4e63..5ee1436 100644 --- a/README.md +++ b/README.md @@ -28,21 +28,56 @@ Special thanks to the following people for their support and contributions to th If you'd like to contribute, please jump right into the source code and create a pull request, or, file an issue with your enhancement request. -## New in v6.0.x +## New in v6.1.0 + +### Performance +- Rewrote message header parsing to eliminate O(n^2) array allocations and per-byte LINQ overhead; now uses a ```MemoryStream``` accumulator with direct byte comparison +- Send operations now use ```ArrayPool``` pooling instead of allocating new buffers on every iteration + +### Thread Safety +- Consolidated ```ClientMetadataManager``` from 5 independent ```ReaderWriterLockSlim``` instances to a single lock, eliminating race conditions during multi-dictionary operations (```ReplaceGuid```, ```Remove```) +- Fixed TOCTOU race in ```GetClient()``` (```ContainsKey``` then indexer across separate lock acquisitions); now uses ```TryGetValue``` +- Replaced ```AutoResetEvent``` + event-based sync response matching with ```ConcurrentDictionary>``` in both client and server, eliminating handler registration race conditions and signal loss + +### Bug Fixes +- Fixed ```WaitHandle``` resource leak in ```WatsonTcpClient.Connect()``` (was commented out, now properly closed) +- Replaced busy-wait spin loops in ```ClientMetadata.Dispose()``` and ```WatsonTcpClient.Disconnect()``` with ```Task.Wait(timeout)``` +- Stale kicked/timed-out client records now automatically purged every 60 seconds (previously accumulated forever) + +### New Features +- ```Settings.MaxHeaderSize``` (client and server, default 256KB) guards against memory exhaustion from oversized or malicious headers +- ```Settings.EnforceMaxConnections``` (server, default ```true```) actively rejects connections at capacity; set to ```false``` for legacy behavior + +### Observability +- Added debug-level logging to all previously silent ```TaskCanceledException``` and ```OperationCanceledException``` catch blocks + +### Testing +- 10 new automated tests (46 total) covering MaxConnections enforcement, MaxHeaderSize validation, rapid connect/disconnect, concurrent sync requests, SSL, server stop detection, duplicate GUIDs, and send-with-offset + +### Breaking Changes +- ```Settings.EnforceMaxConnections``` defaults to ```true```. If you relied on accepting connections beyond ```MaxConnections```, set ```EnforceMaxConnections = false```. +- All other changes are internal with identical public API and wire protocol. + +## Previous in v6.0.x - Remove unsupported frameworks - Async version of ```SyncMessageReceived``` callback - Moving usings inside namespace - Remove obsolete methods -- Mark non-async APIs obsolete +- Mark non-async APIs obsolete - Modified test projects to use async - Ensured background tasks honored cancellation tokens - Ability to specify a client's GUID before attempting to connect -- Remove obsolete methods + +## Architecture + +Refer to [ARCHITECTURE.md](ARCHITECTURE.md) for a detailed overview of the internal design, message flow, threading model, and key design decisions. + +For the wire protocol specification (header format, delimiter, payload layout), see [FRAMING.md](FRAMING.md). ## Test Applications -Test projects for both client and server are included which will help you understand and exercise the class library. +Test projects for both client and server are included which will help you understand and exercise the class library. The `Test.XUnit` project provides `dotnet test`-compatible xUnit tests suitable for CI/CD pipelines. ## SSL @@ -76,7 +111,7 @@ This is not necessary if you are using simple types (int, string, etc). Simply **IMPORTANT** -Identifying the demarcation between message header and payload is CPU intensive and requires evaluation of the tail end of an internally-managed buffer. This process of evaluation is performed for *each byte read* until the end of the header is reached. Thus, is it recommended that the metadata property be used sparingly and with very small amounts of data (less than 1KB). When used with large amounts of data, CPU utilization will increase dramatically and response time will be very slow. +Metadata is serialized into the message header as JSON, increasing header size. While v6.1.0 significantly improved header parsing performance (eliminating O(n^2) allocations), it is still recommended to keep metadata small (less than 1KB) as large metadata increases JSON serialization overhead and network transfer time. Use ```Settings.MaxHeaderSize``` to control the maximum allowed header size (default 256KB). ### Local vs External Connections diff --git a/src/Test.Automated/Program.cs b/src/Test.Automated/Program.cs index 6c0e371..9012515 100644 --- a/src/Test.Automated/Program.cs +++ b/src/Test.Automated/Program.cs @@ -70,6 +70,7 @@ static async Task Main(string[] args) Console.WriteLine(); _framework = new TestFramework(); + _framework.StartSuite(); // Run all tests await RunAllTests(); @@ -151,6 +152,37 @@ static async Task RunAllTests() await Test_AuthenticationSuccess(); await Test_AuthenticationFailure(); await Test_AuthenticationCallback(); + + // Throughput tests + await Test_ThroughputSmallMessages(); + await Test_ThroughputMediumMessages(); + await Test_ThroughputLargeMessages(); + + // v6.1.0 - MaxConnections enforcement tests + await Test_MaxConnectionsEnforced(); + await Test_MaxConnectionsNotEnforced(); + + // v6.1.0 - MaxHeaderSize tests + await Test_MaxHeaderSizeEnforced(); + + // v6.1.0 - Rapid connect/disconnect + await Test_RapidConnectDisconnect(); + + // v6.1.0 - Concurrent sync requests + await Test_ConcurrentSyncRequests(); + + // v6.1.0 - SSL connectivity + await Test_SslConnectivity(); + await Test_SslMessageExchange(); + + // v6.1.0 - Server stop while client connected + await Test_ServerStopWhileClientSending(); + + // v6.1.0 - Duplicate client GUID + await Test_DuplicateClientGuid(); + + // v6.1.0 - Send with offset + await Test_SendWithOffset(); } #region Basic-Connection-Tests @@ -158,6 +190,7 @@ static async Task RunAllTests() static async Task Test_BasicServerStartStop() { string testName = "Basic Server Start/Stop"; + _framework.StartTest(testName); WatsonTcpServer server = null; try { @@ -199,6 +232,7 @@ static async Task Test_BasicServerStartStop() static async Task Test_BasicClientConnection() { string testName = "Basic Client Connection"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; @@ -240,6 +274,7 @@ static async Task Test_BasicClientConnection() static async Task Test_ClientServerConnection() { string testName = "Client-Server Connection"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; @@ -284,6 +319,7 @@ static async Task Test_ClientServerConnection() static async Task Test_ClientSendServerReceive() { string testName = "Client Send -> Server Receive"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; string receivedData = null; @@ -339,6 +375,7 @@ static async Task Test_ClientSendServerReceive() static async Task Test_ServerSendClientReceive() { string testName = "Server Send -> Client Receive"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; string receivedData = null; @@ -395,6 +432,7 @@ static async Task Test_ServerSendClientReceive() static async Task Test_BidirectionalCommunication() { string testName = "Bidirectional Communication"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; string serverReceived = null; @@ -467,6 +505,7 @@ static async Task Test_BidirectionalCommunication() static async Task Test_EmptyMessage() { string testName = "Empty Message with Metadata"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; Dictionary receivedMetadata = null; @@ -526,6 +565,7 @@ static async Task Test_EmptyMessage() static async Task Test_SendWithMetadata() { string testName = "Send With Metadata"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; Dictionary receivedMetadata = null; @@ -595,6 +635,7 @@ static async Task Test_SendWithMetadata() static async Task Test_ReceiveWithMetadata() { string testName = "Receive With Metadata"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; Dictionary receivedMetadata = null; @@ -655,6 +696,7 @@ static async Task Test_ReceiveWithMetadata() static async Task Test_SyncRequestResponse() { string testName = "Sync Request/Response"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; @@ -708,6 +750,7 @@ static async Task Test_SyncRequestResponse() static async Task Test_SyncRequestTimeout() { string testName = "Sync Request Timeout"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; @@ -767,6 +810,7 @@ static async Task Test_SyncRequestTimeout() static async Task Test_ServerConnectedEvent() { string testName = "Server Connected Event"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; bool eventFired = false; @@ -818,6 +862,7 @@ static async Task Test_ServerConnectedEvent() static async Task Test_ServerDisconnectedEvent() { string testName = "Server Disconnected Event"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; bool eventFired = false; @@ -874,6 +919,7 @@ static async Task Test_ServerDisconnectedEvent() static async Task Test_ClientConnectedEvent() { string testName = "Client Connected Event"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; bool eventFired = false; @@ -927,6 +973,7 @@ static async Task Test_ClientConnectedEvent() static async Task Test_ClientDisconnectedEvent() { string testName = "Client Disconnected Event"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; bool eventFired = false; @@ -981,6 +1028,7 @@ static async Task Test_ClientDisconnectedEvent() static async Task Test_MessageReceivedEvent() { string testName = "Message Received Event"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; int messageCount = 0; @@ -1039,6 +1087,7 @@ static async Task Test_MessageReceivedEvent() static async Task Test_StreamSendReceive() { string testName = "Stream Send/Receive"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; long receivedLength = 0; @@ -1099,6 +1148,7 @@ static async Task Test_StreamSendReceive() static async Task Test_LargeStreamTransfer() { string testName = "Large Stream Transfer (10MB)"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; long receivedLength = 0; @@ -1200,6 +1250,7 @@ static async Task Test_LargeStreamTransfer() static async Task Test_ClientStatistics() { string testName = "Client Statistics"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; @@ -1257,6 +1308,7 @@ static async Task Test_ClientStatistics() static async Task Test_ServerStatistics() { string testName = "Server Statistics"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; @@ -1318,6 +1370,7 @@ static async Task Test_ServerStatistics() static async Task Test_MultipleClients() { string testName = "Multiple Clients"; + _framework.StartTest(testName); WatsonTcpServer server = null; List clients = new List(); @@ -1375,6 +1428,7 @@ static async Task Test_MultipleClients() static async Task Test_ListClients() { string testName = "List Clients"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client1 = null; WatsonTcpClient client2 = null; @@ -1436,6 +1490,7 @@ static async Task Test_ListClients() static async Task Test_ClientDisconnect() { string testName = "Client Disconnect"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; @@ -1484,6 +1539,7 @@ static async Task Test_ClientDisconnect() static async Task Test_ServerDisconnectClient() { string testName = "Server Disconnect Client"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; bool clientDisconnected = false; @@ -1546,6 +1602,7 @@ static async Task Test_ServerDisconnectClient() static async Task Test_ServerStop() { string testName = "Server Stop Disconnects Clients"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; bool clientDisconnected = false; @@ -1624,6 +1681,7 @@ static async Task Test_ServerStop() static async Task Test_LargeMessageTransfer() { string testName = "Large Message Transfer (1MB)"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; byte[] receivedData = null; @@ -1695,6 +1753,7 @@ static async Task Test_LargeMessageTransfer() static async Task Test_ManyMessages() { string testName = "Many Messages (100 messages)"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; int receivedCount = 0; @@ -1754,6 +1813,7 @@ static async Task Test_ManyMessages() static async Task Test_SendToNonExistentClient() { string testName = "Send To Non-Existent Client"; + _framework.StartTest(testName); WatsonTcpServer server = null; try @@ -1802,6 +1862,7 @@ static async Task Test_SendToNonExistentClient() static async Task Test_ConnectToNonExistentServer() { string testName = "Connect To Non-Existent Server"; + _framework.StartTest(testName); WatsonTcpClient client = null; try @@ -1848,6 +1909,7 @@ static async Task Test_ConnectToNonExistentServer() static async Task Test_ConcurrentClientConnections() { string testName = "Concurrent Client Connections"; + _framework.StartTest(testName); WatsonTcpServer server = null; List clients = new List(); @@ -1899,6 +1961,7 @@ static async Task Test_ConcurrentClientConnections() static async Task Test_ConcurrentMessageSends() { string testName = "Concurrent Message Sends"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; int receivedCount = 0; @@ -1966,6 +2029,7 @@ static async Task Test_ConcurrentMessageSends() static async Task Test_SpecifyClientGuid() { string testName = "Specify Client GUID"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; Guid? receivedGuid = null; @@ -2023,6 +2087,7 @@ static async Task Test_SpecifyClientGuid() static async Task Test_IdleClientTimeout() { string testName = "Idle Client Timeout"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; bool clientDisconnected = false; @@ -2081,6 +2146,7 @@ static async Task Test_IdleClientTimeout() static async Task Test_AuthenticationSuccess() { string testName = "Authentication Success (via Settings)"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; bool authSucceeded = false; @@ -2138,6 +2204,7 @@ static async Task Test_AuthenticationSuccess() static async Task Test_AuthenticationFailure() { string testName = "Authentication Failure (Wrong Key)"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; bool authFailed = false; @@ -2210,6 +2277,7 @@ static async Task Test_AuthenticationFailure() static async Task Test_AuthenticationCallback() { string testName = "Authentication Callback (Fallback)"; + _framework.StartTest(testName); WatsonTcpServer server = null; WatsonTcpClient client = null; bool authSucceeded = false; @@ -2276,66 +2344,885 @@ static async Task Test_AuthenticationCallback() } #endregion - } - #region Test-Framework + #region Throughput-Tests - class TestFramework - { - private List _results = new List(); - private object _lock = new object(); + static async Task Test_ThroughputSmallMessages() + { + string testName = "Throughput: 64-byte messages"; + _framework.StartTest(testName); + await RunThroughputTest(testName, messageSize: 64, messageCount: 5000); + } - public void RecordSuccess(string testName) + static async Task Test_ThroughputMediumMessages() { - lock (_lock) + string testName = "Throughput: 64KB messages"; + _framework.StartTest(testName); + await RunThroughputTest(testName, messageSize: 65536, messageCount: 500); + } + + static async Task Test_ThroughputLargeMessages() + { + string testName = "Throughput: 4MB messages"; + _framework.StartTest(testName); + await RunThroughputTest(testName, messageSize: 4 * 1024 * 1024, messageCount: 20); + } + + static async Task RunThroughputTest(string testName, int messageSize, int messageCount) + { + WatsonTcpServer server = null; + WatsonTcpClient client = null; + int receivedCount = 0; + ManualResetEvent allReceived = new ManualResetEvent(false); + + try + { + int port = GetNextPort(); + server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Events.MessageReceived += (s, e) => + { + if (Interlocked.Increment(ref receivedCount) >= messageCount) + allReceived.Set(); + }; + server.Start(); + await Task.Delay(100); + + client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + byte[] data = new byte[messageSize]; + new Random(42).NextBytes(data); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + + for (int i = 0; i < messageCount; i++) + { + bool sent = await client.SendAsync(data); + if (!sent) + { + _framework.RecordFailure(testName, $"Send failed at message {i}"); + return; + } + } + + int timeoutMs = Math.Max(30000, messageCount * 100); + if (!allReceived.WaitOne(timeoutMs)) + { + _framework.RecordFailure(testName, $"Only {receivedCount}/{messageCount} messages received within timeout"); + return; + } + + sw.Stop(); + + long totalBytes = (long)messageSize * messageCount; + double seconds = sw.Elapsed.TotalSeconds; + double bytesPerSec = totalBytes / seconds; + double msgsPerSec = messageCount / seconds; + + string throughput; + if (bytesPerSec >= 1_073_741_824) + throughput = (bytesPerSec / 1_073_741_824).ToString("F2") + " GB/s"; + else if (bytesPerSec >= 1_048_576) + throughput = (bytesPerSec / 1_048_576).ToString("F1") + " MB/s"; + else if (bytesPerSec >= 1024) + throughput = (bytesPerSec / 1024).ToString("F0") + " KB/s"; + else + throughput = bytesPerSec.ToString("F0") + " B/s"; + + Console.WriteLine($" {messageCount} msgs x {FormatSize(messageSize)} = {FormatSize(totalBytes)} in {seconds:F2}s"); + Console.WriteLine($" {msgsPerSec:F0} msgs/s, {throughput}"); + + _framework.RecordSuccess(testName); + } + catch (Exception ex) + { + _framework.RecordFailure(testName, ex.Message); + } + finally { - _results.Add(new TestResult { TestName = testName, Passed = true }); - Console.WriteLine($"[PASS] {testName}"); + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); } } - public void RecordFailure(string testName, string reason) + static string FormatSize(long bytes) { - lock (_lock) + if (bytes >= 1_073_741_824) return (bytes / 1_073_741_824.0).ToString("F1") + " GB"; + if (bytes >= 1_048_576) return (bytes / 1_048_576.0).ToString("F1") + " MB"; + if (bytes >= 1024) return (bytes / 1024.0).ToString("F0") + " KB"; + return bytes + " B"; + } + + #endregion + + #region v6.1.0-MaxConnections-Tests + + static async Task Test_MaxConnectionsEnforced() + { + string testName = "MaxConnections Enforced (v6.1.0)"; + _framework.StartTest(testName); + WatsonTcpServer server = null; + List clients = new List(); + + try { - _results.Add(new TestResult { TestName = testName, Passed = false, FailureReason = reason }); - Console.WriteLine($"[FAIL] {testName}"); - Console.WriteLine($" Reason: {reason}"); + int port = GetNextPort(); + server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Settings.MaxConnections = 2; + server.Settings.EnforceMaxConnections = true; + server.Start(); + await Task.Delay(100); + + // Connect 2 clients (should succeed) + for (int i = 0; i < 2; i++) + { + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + clients.Add(client); + await Task.Delay(200); + } + + if (server.Connections != 2) + { + _framework.RecordFailure(testName, $"Expected 2 connections, got {server.Connections}"); + return; + } + + // 3rd client - should fail to connect or be immediately disconnected + bool thirdClientFailed = false; + WatsonTcpClient thirdClient = null; + try + { + thirdClient = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(thirdClient); + thirdClient.Connect(); + await Task.Delay(500); + // Even if the TCP connection succeeded, the server should have closed it + // Check that the server still only has 2 connections + if (server.Connections <= 2) thirdClientFailed = true; + } + catch + { + thirdClientFailed = true; + } + finally + { + SafeDispose(thirdClient); + } + + if (!thirdClientFailed) + { + _framework.RecordFailure(testName, "Third client should have been rejected but server accepted it"); + return; + } + + _framework.RecordSuccess(testName); + } + catch (Exception ex) + { + _framework.RecordFailure(testName, ex.Message); + } + finally + { + foreach (var c in clients) SafeDispose(c); + SafeDispose(server); + await Task.Delay(100); } } - public void PrintSummary() + static async Task Test_MaxConnectionsNotEnforced() { - Console.WriteLine(); - foreach (var result in _results) + string testName = "MaxConnections Not Enforced (Legacy, v6.1.0)"; + _framework.StartTest(testName); + WatsonTcpServer server = null; + List clients = new List(); + + try { - string status = result.Passed ? "PASS" : "FAIL"; - Console.WriteLine($"[{status}] {result.TestName}"); + int port = GetNextPort(); + server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Settings.MaxConnections = 2; + server.Settings.EnforceMaxConnections = false; + server.Start(); + await Task.Delay(100); + + // Connect 3 clients - all should succeed when enforcement is off + for (int i = 0; i < 3; i++) + { + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + clients.Add(client); + await Task.Delay(200); + } + + await Task.Delay(500); + + if (server.Connections < 3) + { + _framework.RecordFailure(testName, $"Expected 3 connections with enforcement off, got {server.Connections}"); + return; + } + + _framework.RecordSuccess(testName); + } + catch (Exception ex) + { + _framework.RecordFailure(testName, ex.Message); + } + finally + { + foreach (var c in clients) SafeDispose(c); + SafeDispose(server); + await Task.Delay(100); } + } - Console.WriteLine(); - int passed = _results.Count(r => r.Passed); - int failed = _results.Count(r => !r.Passed); - int total = _results.Count; + #endregion - Console.WriteLine($"Total: {total} tests"); - Console.WriteLine($"Passed: {passed}"); - Console.WriteLine($"Failed: {failed}"); - Console.WriteLine(); + #region v6.1.0-MaxHeaderSize-Tests - if (failed == 0) + static async Task Test_MaxHeaderSizeEnforced() + { + string testName = "MaxHeaderSize Setting (v6.1.0)"; + _framework.StartTest(testName); + WatsonTcpServer server = null; + WatsonTcpClient client = null; + + try { - Console.WriteLine("==============================================="); - Console.WriteLine("OVERALL RESULT: PASS"); - Console.WriteLine("==============================================="); + int port = GetNextPort(); + server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + // Verify the setting can be set + server.Settings.MaxHeaderSize = 1024; + if (server.Settings.MaxHeaderSize != 1024) + { + _framework.RecordFailure(testName, "MaxHeaderSize not properly set on server"); + return; + } + + server.Start(); + await Task.Delay(100); + + client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Settings.MaxHeaderSize = 2048; + if (client.Settings.MaxHeaderSize != 2048) + { + _framework.RecordFailure(testName, "MaxHeaderSize not properly set on client"); + return; + } + + client.Connect(); + await Task.Delay(200); + + // Verify normal messages still work with reasonable header sizes + string testData = "Hello with MaxHeaderSize set"; + ManualResetEvent received = new ManualResetEvent(false); + string receivedData = null; + server.Events.MessageReceived += (s, e) => + { + receivedData = Encoding.UTF8.GetString(e.Data); + received.Set(); + }; + + await client.SendAsync(testData); + if (!received.WaitOne(5000)) + { + _framework.RecordFailure(testName, "Message not received with MaxHeaderSize set"); + return; + } + + if (receivedData != testData) + { + _framework.RecordFailure(testName, $"Expected '{testData}', got '{receivedData}'"); + return; + } + + // Verify that setting an invalid MaxHeaderSize throws + bool exceptionThrown = false; + try + { + server.Settings.MaxHeaderSize = 10; // too small, must be > 24 + } + catch (ArgumentException) + { + exceptionThrown = true; + } + + if (!exceptionThrown) + { + _framework.RecordFailure(testName, "Setting MaxHeaderSize too small should throw"); + return; + } + + _framework.RecordSuccess(testName); } - else + catch (Exception ex) { - Console.WriteLine("==============================================="); - Console.WriteLine("OVERALL RESULT: FAIL"); - Console.WriteLine("==============================================="); + _framework.RecordFailure(testName, ex.Message); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); } } + + #endregion + + #region v6.1.0-Rapid-Connect-Disconnect + + static async Task Test_RapidConnectDisconnect() + { + string testName = "Rapid Connect/Disconnect (v6.1.0)"; + _framework.StartTest(testName); + WatsonTcpServer server = null; + + try + { + int port = GetNextPort(); + server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + int iterations = 10; + for (int i = 0; i < iterations; i++) + { + WatsonTcpClient client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(50); + SafeDispose(client); + await Task.Delay(50); + } + + await Task.Delay(500); + + // Server should still be running with no connected clients + if (!server.IsListening) + { + _framework.RecordFailure(testName, "Server stopped listening after rapid connect/disconnect"); + return; + } + + _framework.RecordSuccess(testName); + } + catch (Exception ex) + { + _framework.RecordFailure(testName, ex.Message); + } + finally + { + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region v6.1.0-Concurrent-Sync-Requests + + static async Task Test_ConcurrentSyncRequests() + { + string testName = "Concurrent Sync Requests (v6.1.0)"; + _framework.StartTest(testName); + WatsonTcpServer server = null; + WatsonTcpClient client = null; + + try + { + int port = GetNextPort(); + server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Callbacks.SyncRequestReceivedAsync = async (req) => + { + // Echo back with "Reply:" prefix + string requestData = Encoding.UTF8.GetString(req.Data); + await Task.Delay(50); // simulate processing + return new SyncResponse(req, "Reply:" + requestData); + }; + server.Start(); + await Task.Delay(100); + + client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + // Send 5 concurrent sync requests + int numRequests = 5; + Task[] tasks = new Task[numRequests]; + for (int i = 0; i < numRequests; i++) + { + int idx = i; + tasks[i] = client.SendAndWaitAsync(10000, "Request" + idx); + } + + SyncResponse[] responses = await Task.WhenAll(tasks); + + // Verify all responses + HashSet expectedResponses = new HashSet(); + for (int i = 0; i < numRequests; i++) + expectedResponses.Add("Reply:Request" + i); + + HashSet actualResponses = new HashSet(); + foreach (var resp in responses) + { + if (resp == null || resp.Data == null) + { + _framework.RecordFailure(testName, "Null response received"); + return; + } + actualResponses.Add(Encoding.UTF8.GetString(resp.Data)); + } + + if (!expectedResponses.SetEquals(actualResponses)) + { + _framework.RecordFailure(testName, "Responses don't match expected values. Expected: " + string.Join(", ", expectedResponses) + " Got: " + string.Join(", ", actualResponses)); + return; + } + + _framework.RecordSuccess(testName); + } + catch (Exception ex) + { + _framework.RecordFailure(testName, ex.Message); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region v6.1.0-SSL-Tests + + static async Task Test_SslConnectivity() + { + string testName = "SSL Connectivity (v6.1.0)"; + _framework.StartTest(testName); + WatsonTcpServer server = null; + WatsonTcpClient client = null; + + try + { + string pfxFile = "test.pfx"; + if (!File.Exists(pfxFile)) + { + _framework.RecordSuccess(testName + " [SKIPPED - no test.pfx]"); + return; + } + + int port = GetNextPort(); + server = new WatsonTcpServer(_hostname, port, pfxFile, "password"); + SetupDefaultServerHandlers(server); + server.Settings.AcceptInvalidCertificates = true; + server.Start(); + await Task.Delay(100); + + client = new WatsonTcpClient(_hostname, port, pfxFile, "password"); + SetupDefaultClientHandlers(client); + client.Settings.AcceptInvalidCertificates = true; + client.Connect(); + await Task.Delay(200); + + if (!client.Connected) + { + _framework.RecordFailure(testName, "SSL client not connected"); + return; + } + + var clients = server.ListClients().ToList(); + if (clients.Count != 1) + { + _framework.RecordFailure(testName, $"Expected 1 SSL client, found {clients.Count}"); + return; + } + + _framework.RecordSuccess(testName); + } + catch (Exception ex) + { + _framework.RecordFailure(testName, ex.Message); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + static async Task Test_SslMessageExchange() + { + string testName = "SSL Message Exchange (v6.1.0)"; + _framework.StartTest(testName); + WatsonTcpServer server = null; + WatsonTcpClient client = null; + ManualResetEvent messageReceived = new ManualResetEvent(false); + string receivedData = null; + + try + { + string pfxFile = "test.pfx"; + if (!File.Exists(pfxFile)) + { + _framework.RecordSuccess(testName + " [SKIPPED - no test.pfx]"); + return; + } + + int port = GetNextPort(); + server = new WatsonTcpServer(_hostname, port, pfxFile, "password"); + server.Settings.AcceptInvalidCertificates = true; + server.Events.MessageReceived += (s, e) => + { + receivedData = Encoding.UTF8.GetString(e.Data); + messageReceived.Set(); + }; + server.Start(); + await Task.Delay(100); + + client = new WatsonTcpClient(_hostname, port, pfxFile, "password"); + SetupDefaultClientHandlers(client); + client.Settings.AcceptInvalidCertificates = true; + client.Connect(); + await Task.Delay(200); + + string testData = "Hello over SSL!"; + await client.SendAsync(testData); + + if (!messageReceived.WaitOne(5000)) + { + _framework.RecordFailure(testName, "Server did not receive SSL message"); + return; + } + + if (receivedData != testData) + { + _framework.RecordFailure(testName, $"Expected '{testData}', got '{receivedData}'"); + return; + } + + _framework.RecordSuccess(testName); + } + catch (Exception ex) + { + _framework.RecordFailure(testName, ex.Message); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region v6.1.0-Edge-Cases + + static async Task Test_ServerStopWhileClientSending() + { + string testName = "Server Stop While Client Connected (v6.1.0)"; + _framework.StartTest(testName); + WatsonTcpServer server = null; + WatsonTcpClient client = null; + bool disconnectDetected = false; + ManualResetEvent disconnectEvent = new ManualResetEvent(false); + + try + { + int port = GetNextPort(); + server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Events.ServerDisconnected += (s, e) => + { + disconnectDetected = true; + disconnectEvent.Set(); + }; + client.Connect(); + await Task.Delay(200); + + // Stop the server while client is connected + SafeDispose(server); + server = null; + + // Try to send a message to trigger disconnect detection + try + { + await client.SendAsync("trigger disconnect"); + } + catch { } + + // Client should detect the disconnect + disconnectEvent.WaitOne(10000); + + // Also check that the client is no longer connected + if (!disconnectDetected && client.Connected) + { + _framework.RecordFailure(testName, "Client did not detect server stop"); + return; + } + + _framework.RecordSuccess(testName); + } + catch (Exception ex) + { + _framework.RecordFailure(testName, ex.Message); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + static async Task Test_DuplicateClientGuid() + { + string testName = "Duplicate Client GUID (v6.1.0)"; + _framework.StartTest(testName); + WatsonTcpServer server = null; + WatsonTcpClient client1 = null; + WatsonTcpClient client2 = null; + + try + { + int port = GetNextPort(); + server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + Guid sharedGuid = Guid.NewGuid(); + + client1 = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client1); + client1.Settings.Guid = sharedGuid; + client1.Connect(); + await Task.Delay(300); + + // Second client with the same GUID + client2 = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client2); + client2.Settings.Guid = sharedGuid; + client2.Connect(); + await Task.Delay(500); + + // At least one client should be connected + // The behavior may vary - the second connection should replace the first + // or the server should handle it gracefully + bool atLeastOneConnected = client1.Connected || client2.Connected; + if (!atLeastOneConnected) + { + _framework.RecordFailure(testName, "Neither client is connected with duplicate GUID"); + return; + } + + _framework.RecordSuccess(testName); + } + catch (Exception ex) + { + _framework.RecordFailure(testName, ex.Message); + } + finally + { + SafeDispose(client1); + SafeDispose(client2); + SafeDispose(server); + await Task.Delay(100); + } + } + + static async Task Test_SendWithOffset() + { + string testName = "Send With Byte Array Offset (v6.1.0)"; + _framework.StartTest(testName); + WatsonTcpServer server = null; + WatsonTcpClient client = null; + ManualResetEvent messageReceived = new ManualResetEvent(false); + byte[] receivedBytes = null; + + try + { + int port = GetNextPort(); + server = new WatsonTcpServer(_hostname, port); + server.Events.MessageReceived += (s, e) => + { + receivedBytes = e.Data; + messageReceived.Set(); + }; + server.Start(); + await Task.Delay(100); + + client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + // Send with an offset - skip first 5 bytes + byte[] fullData = Encoding.UTF8.GetBytes("HEADERHello World"); + int offset = 6; // skip "HEADER" + await client.SendAsync(fullData, null, offset); + + if (!messageReceived.WaitOne(5000)) + { + _framework.RecordFailure(testName, "Message not received"); + return; + } + + string received = Encoding.UTF8.GetString(receivedBytes); + if (received != "Hello World") + { + _framework.RecordFailure(testName, $"Expected 'Hello World', got '{received}'"); + return; + } + + _framework.RecordSuccess(testName); + } + catch (Exception ex) + { + _framework.RecordFailure(testName, ex.Message); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + } + + #region Test-Framework + + class TestFramework + { + private List _results = new List(); + private object _lock = new object(); + private Dictionary _timers = new Dictionary(); + private System.Diagnostics.Stopwatch _totalTimer = new System.Diagnostics.Stopwatch(); + + public void StartSuite() + { + _totalTimer.Start(); + } + + public void StartTest(string testName) + { + lock (_lock) + { + var sw = new System.Diagnostics.Stopwatch(); + sw.Start(); + _timers[testName] = sw; + } + } + + public void RecordSuccess(string testName) + { + lock (_lock) + { + TimeSpan elapsed = StopTimer(testName); + _results.Add(new TestResult { TestName = testName, Passed = true, Duration = elapsed }); + Console.WriteLine($"[PASS] {testName} ({FormatDuration(elapsed)})"); + } + } + + public void RecordFailure(string testName, string reason) + { + lock (_lock) + { + TimeSpan elapsed = StopTimer(testName); + _results.Add(new TestResult { TestName = testName, Passed = false, FailureReason = reason, Duration = elapsed }); + Console.WriteLine($"[FAIL] {testName} ({FormatDuration(elapsed)})"); + Console.WriteLine($" Reason: {reason}"); + } + } + + public void PrintSummary() + { + _totalTimer.Stop(); + + Console.WriteLine(); + foreach (var result in _results) + { + string status = result.Passed ? "PASS" : "FAIL"; + Console.WriteLine($"[{status}] {result.TestName} ({FormatDuration(result.Duration)})"); + } + + Console.WriteLine(); + int passed = _results.Count(r => r.Passed); + int failed = _results.Count(r => !r.Passed); + int total = _results.Count; + + Console.WriteLine($"Total: {total} tests"); + Console.WriteLine($"Passed: {passed}"); + Console.WriteLine($"Failed: {failed}"); + Console.WriteLine($"Duration: {FormatDuration(_totalTimer.Elapsed)}"); + + if (failed > 0) + { + Console.WriteLine(); + Console.WriteLine("Failed tests:"); + foreach (var result in _results.Where(r => !r.Passed)) + { + Console.WriteLine($" - {result.TestName}: {result.FailureReason}"); + } + } + + Console.WriteLine(); + + if (failed == 0) + { + Console.WriteLine("==============================================="); + Console.WriteLine("OVERALL RESULT: PASS"); + Console.WriteLine("==============================================="); + } + else + { + Console.WriteLine("==============================================="); + Console.WriteLine("OVERALL RESULT: FAIL"); + Console.WriteLine("==============================================="); + } + } + + private TimeSpan StopTimer(string testName) + { + if (_timers.TryGetValue(testName, out var sw)) + { + sw.Stop(); + _timers.Remove(testName); + return sw.Elapsed; + } + return TimeSpan.Zero; + } + + private string FormatDuration(TimeSpan ts) + { + if (ts.TotalSeconds >= 1.0) + return ts.TotalSeconds.ToString("F2") + "s"; + return ts.TotalMilliseconds.ToString("F0") + "ms"; + } } class TestResult @@ -2343,6 +3230,7 @@ class TestResult public string TestName { get; set; } public bool Passed { get; set; } public string FailureReason { get; set; } + public TimeSpan Duration { get; set; } } #endregion diff --git a/src/Test.XUnit/Test.XUnit.csproj b/src/Test.XUnit/Test.XUnit.csproj new file mode 100644 index 0000000..f1040eb --- /dev/null +++ b/src/Test.XUnit/Test.XUnit.csproj @@ -0,0 +1,32 @@ + + + + net8.0;net10.0 + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + Always + + + PreserveNewest + + + + diff --git a/src/Test.XUnit/WatsonTcpTests.cs b/src/Test.XUnit/WatsonTcpTests.cs new file mode 100644 index 0000000..a315acb --- /dev/null +++ b/src/Test.XUnit/WatsonTcpTests.cs @@ -0,0 +1,1653 @@ +namespace Test.XUnit +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using WatsonTcp; + using Xunit; + + /// + /// xUnit mirror of Test.Automated tests. + /// Tests run sequentially within this collection to avoid port conflicts. + /// + [Collection("WatsonTcp")] + public class WatsonTcpTests : IDisposable + { + private static int _portCounter = 20000 + (System.Diagnostics.Process.GetCurrentProcess().Id % 10000); + private static readonly object _portLock = new object(); + private readonly string _hostname = "127.0.0.1"; + + private static int GetNextPort() + { + lock (_portLock) + { + return _portCounter++; + } + } + + private static void SetupDefaultServerHandlers(WatsonTcpServer server) + { + server.Events.MessageReceived += (s, e) => { }; + } + + private static void SetupDefaultClientHandlers(WatsonTcpClient client) + { + client.Events.MessageReceived += (s, e) => { }; + } + + private static void SafeDispose(IDisposable obj) + { + try { obj?.Dispose(); } + catch (AggregateException) { } + } + + public void Dispose() { GC.SuppressFinalize(this); } + + #region Basic-Connection-Tests + + [Fact] + public async Task BasicServerStartStop() + { + int port = GetNextPort(); + using var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + Assert.True(server.IsListening); + + server.Stop(); + await Task.Delay(200); + + Assert.False(server.IsListening); + } + + [Fact] + public async Task BasicClientConnection() + { + int port = GetNextPort(); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + Assert.True(client.Connected); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task ClientServerConnection() + { + int port = GetNextPort(); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + var clients = server.ListClients().ToList(); + Assert.Single(clients); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region Message-Send-Receive-Tests + + [Fact] + public async Task ClientSendServerReceive() + { + int port = GetNextPort(); + string receivedData = null; + var messageReceived = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Events.MessageReceived += (s, e) => + { + receivedData = Encoding.UTF8.GetString(e.Data); + messageReceived.Set(); + }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + string testData = "Hello from client!"; + await client.SendAsync(testData); + Assert.True(messageReceived.WaitOne(5000), "Server did not receive message"); + Assert.Equal(testData, receivedData); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task ServerSendClientReceive() + { + int port = GetNextPort(); + string receivedData = null; + var messageReceived = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Events.MessageReceived += (s, e) => + { + receivedData = Encoding.UTF8.GetString(e.Data); + messageReceived.Set(); + }; + client.Connect(); + await Task.Delay(200); + + try + { + string testData = "Hello from server!"; + var clients = server.ListClients().ToList(); + await server.SendAsync(clients[0].Guid, testData); + Assert.True(messageReceived.WaitOne(5000), "Client did not receive message"); + Assert.Equal(testData, receivedData); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task BidirectionalCommunication() + { + int port = GetNextPort(); + string serverReceived = null; + string clientReceived = null; + var serverGotMessage = new ManualResetEvent(false); + var clientGotMessage = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Events.MessageReceived += (s, e) => { serverReceived = Encoding.UTF8.GetString(e.Data); serverGotMessage.Set(); }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Events.MessageReceived += (s, e) => { clientReceived = Encoding.UTF8.GetString(e.Data); clientGotMessage.Set(); }; + client.Connect(); + await Task.Delay(200); + + try + { + await client.SendAsync("From client"); + Assert.True(serverGotMessage.WaitOne(5000)); + Assert.Equal("From client", serverReceived); + + var clients = server.ListClients().ToList(); + await server.SendAsync(clients[0].Guid, "From server"); + Assert.True(clientGotMessage.WaitOne(5000)); + Assert.Equal("From server", clientReceived); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task EmptyMessageWithMetadata() + { + int port = GetNextPort(); + Dictionary receivedMetadata = null; + var messageReceived = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Events.MessageReceived += (s, e) => { receivedMetadata = e.Metadata; messageReceived.Set(); }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + var metadata = new Dictionary { { "test", "value" } }; + await client.SendAsync("", metadata); + Assert.True(messageReceived.WaitOne(5000)); + Assert.NotNull(receivedMetadata); + Assert.True(receivedMetadata.ContainsKey("test")); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region Metadata-Tests + + [Fact] + public async Task SendWithMetadata() + { + int port = GetNextPort(); + Dictionary receivedMetadata = null; + string receivedData = null; + var messageReceived = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Events.MessageReceived += (s, e) => { receivedData = Encoding.UTF8.GetString(e.Data); receivedMetadata = e.Metadata; messageReceived.Set(); }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + var metadata = new Dictionary { { "key1", "value1" }, { "key2", 42 }, { "key3", true } }; + await client.SendAsync("Test data", metadata); + Assert.True(messageReceived.WaitOne(5000)); + Assert.Equal("Test data", receivedData); + Assert.NotNull(receivedMetadata); + Assert.Equal(3, receivedMetadata.Count); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task ReceiveWithMetadata() + { + int port = GetNextPort(); + Dictionary receivedMetadata = null; + var messageReceived = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Events.MessageReceived += (s, e) => { receivedMetadata = e.Metadata; messageReceived.Set(); }; + client.Connect(); + await Task.Delay(200); + + try + { + var metadata = new Dictionary { { "server", "data" } }; + var clients = server.ListClients().ToList(); + await server.SendAsync(clients[0].Guid, "Server message", metadata); + Assert.True(messageReceived.WaitOne(5000)); + Assert.NotNull(receivedMetadata); + Assert.True(receivedMetadata.ContainsKey("server")); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region Sync-Request-Response-Tests + + [Fact] + public async Task SyncRequestResponse() + { + int port = GetNextPort(); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Callbacks.SyncRequestReceivedAsync = async (req) => { await Task.Delay(10); return new SyncResponse(req, "Response from server"); }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + SyncResponse response = await client.SendAndWaitAsync(5000, "Request from client"); + Assert.NotNull(response); + Assert.Equal("Response from server", Encoding.UTF8.GetString(response.Data)); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task SyncRequestTimeout() + { + int port = GetNextPort(); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Callbacks.SyncRequestReceivedAsync = async (req) => { await Task.Delay(3000); return new SyncResponse(req, "Too late"); }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + await Assert.ThrowsAsync(() => client.SendAndWaitAsync(1000, "Request")); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region Event-Tests + + [Fact] + public async Task ServerConnectedEvent() + { + int port = GetNextPort(); + bool eventFired = false; + var connectionEvent = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Events.ServerConnected += (s, e) => { eventFired = true; connectionEvent.Set(); }; + client.Connect(); + + try + { + Assert.True(connectionEvent.WaitOne(5000)); + Assert.True(eventFired); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task ServerDisconnectedEvent() + { + int port = GetNextPort(); + bool eventFired = false; + var disconnectionEvent = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Events.ServerDisconnected += (s, e) => { eventFired = true; disconnectionEvent.Set(); }; + client.Connect(); + await Task.Delay(200); + + try + { + var clients = server.ListClients().ToList(); + await server.DisconnectClientAsync(clients[0].Guid); + Assert.True(disconnectionEvent.WaitOne(5000)); + Assert.True(eventFired); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task ClientConnectedEvent() + { + int port = GetNextPort(); + Guid? connectedGuid = null; + var connectionEvent = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Events.ClientConnected += (s, e) => { connectedGuid = e.Client.Guid; connectionEvent.Set(); }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + + try + { + Assert.True(connectionEvent.WaitOne(5000)); + Assert.NotNull(connectedGuid); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task ClientDisconnectedEvent() + { + int port = GetNextPort(); + bool eventFired = false; + var disconnectionEvent = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Events.ClientDisconnected += (s, e) => { eventFired = true; disconnectionEvent.Set(); }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + client.Disconnect(); + Assert.True(disconnectionEvent.WaitOne(5000)); + Assert.True(eventFired); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task MessageReceivedEvent() + { + int port = GetNextPort(); + int messageCount = 0; + var messageEvent = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Events.MessageReceived += (s, e) => { messageCount++; messageEvent.Set(); }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + await client.SendAsync("Test message"); + Assert.True(messageEvent.WaitOne(5000)); + Assert.Equal(1, messageCount); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region Stream-Tests + + [Fact] + public async Task StreamSendReceive() + { + int port = GetNextPort(); + long receivedLength = 0; + var streamReceived = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + server.Events.StreamReceived += (s, e) => + { + receivedLength = e.ContentLength; + byte[] buf = new byte[e.ContentLength]; + int totalRead = 0; + while (totalRead < buf.Length) + { + int n = e.DataStream.Read(buf, totalRead, buf.Length - totalRead); + if (n <= 0) break; + totalRead += n; + } + streamReceived.Set(); + }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + byte[] data = Encoding.UTF8.GetBytes("Stream data test"); + using var ms = new MemoryStream(data); + await client.SendAsync(data.Length, ms); + Assert.True(streamReceived.WaitOne(5000)); + Assert.Equal(data.Length, receivedLength); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task LargeStreamTransfer() + { + int port = GetNextPort(); + long receivedLength = 0; + bool dataVerified = false; + var streamReceived = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + server.Events.StreamReceived += (s, e) => + { + receivedLength = e.ContentLength; + byte[] buffer = new byte[8192]; + long totalRead = 0; + bool valid = true; + while (totalRead < e.ContentLength) + { + int bytesRead = e.DataStream.Read(buffer, 0, buffer.Length); + if (bytesRead <= 0) break; + for (int i = 0; i < bytesRead && valid; i++) + if (buffer[i] != (byte)((totalRead + i) % 256)) valid = false; + totalRead += bytesRead; + } + dataVerified = valid; + streamReceived.Set(); + }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + int dataSize = 10 * 1024 * 1024; + using var ms = new MemoryStream(); + for (long i = 0; i < dataSize; i++) ms.WriteByte((byte)(i % 256)); + ms.Seek(0, SeekOrigin.Begin); + await client.SendAsync(dataSize, ms); + + Assert.True(streamReceived.WaitOne(30000)); + Assert.Equal(dataSize, receivedLength); + Assert.True(dataVerified); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region Statistics-Tests + + [Fact] + public async Task ClientStatistics() + { + int port = GetNextPort(); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + long initialSent = client.Statistics.SentBytes; + await client.SendAsync("Test message"); + await Task.Delay(200); + Assert.True(client.Statistics.SentBytes > initialSent); + + long initialReceived = client.Statistics.ReceivedBytes; + var clients = server.ListClients().ToList(); + await server.SendAsync(clients[0].Guid, "Response"); + await Task.Delay(200); + Assert.True(client.Statistics.ReceivedBytes > initialReceived); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task ServerStatistics() + { + int port = GetNextPort(); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + long initialReceived = server.Statistics.ReceivedBytes; + long initialSent = server.Statistics.SentBytes; + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + await client.SendAsync("Client message"); + await Task.Delay(200); + Assert.True(server.Statistics.ReceivedBytes > initialReceived); + + var clients = server.ListClients().ToList(); + await server.SendAsync(clients[0].Guid, "Server message"); + await Task.Delay(200); + Assert.True(server.Statistics.SentBytes > initialSent); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region Multiple-Client-Tests + + [Fact] + public async Task MultipleClients() + { + int port = GetNextPort(); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + var clients = new List(); + try + { + for (int i = 0; i < 3; i++) + { + var c = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(c); + c.Connect(); + clients.Add(c); + await Task.Delay(200); + } + Assert.Equal(3, server.ListClients().Count()); + } + finally + { + foreach (var c in clients) SafeDispose(c); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task ListClients() + { + int port = GetNextPort(); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + var clientList = server.ListClients().ToList(); + Assert.Single(clientList); + Assert.NotEqual(Guid.Empty, clientList[0].Guid); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region Disconnection-Tests + + [Fact] + public async Task ClientDisconnect() + { + int port = GetNextPort(); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + Assert.True(client.Connected); + client.Disconnect(); + await Task.Delay(200); + Assert.False(client.Connected); + + SafeDispose(client); + SafeDispose(server); + } + + [Fact] + public async Task ServerDisconnectClient() + { + int port = GetNextPort(); + var disconnectEvent = new ManualResetEvent(false); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Events.ServerDisconnected += (s, e) => disconnectEvent.Set(); + client.Connect(); + await Task.Delay(200); + + try + { + var clients = server.ListClients().ToList(); + await server.DisconnectClientAsync(clients[0].Guid); + Assert.True(disconnectEvent.WaitOne(5000)); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task ServerStop() + { + int port = GetNextPort(); + var disconnectEvent = new ManualResetEvent(false); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Events.ServerDisconnected += (s, e) => disconnectEvent.Set(); + client.Connect(); + await Task.Delay(200); + + try + { + SafeDispose(server); + // Trigger disconnect detection by attempting a send + try { await client.SendAsync("trigger"); } catch { } + Assert.True(disconnectEvent.WaitOne(10000)); + } + finally + { + SafeDispose(client); + await Task.Delay(100); + } + } + + #endregion + + #region Large-Data-Tests + + [Fact] + public async Task LargeMessageTransfer() + { + int port = GetNextPort(); + byte[] receivedData = null; + var messageReceived = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Events.MessageReceived += (s, e) => { receivedData = e.Data; messageReceived.Set(); }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + byte[] data = new byte[1024 * 1024]; // 1MB + new Random(42).NextBytes(data); + await client.SendAsync(data); + Assert.True(messageReceived.WaitOne(30000)); + Assert.Equal(data.Length, receivedData.Length); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task ManyMessages() + { + int port = GetNextPort(); + int count = 0; + var allReceived = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Events.MessageReceived += (s, e) => { if (Interlocked.Increment(ref count) >= 100) allReceived.Set(); }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + for (int i = 0; i < 100; i++) await client.SendAsync("Message " + i); + Assert.True(allReceived.WaitOne(30000)); + Assert.Equal(100, count); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region Error-Condition-Tests + + [Fact] + public async Task SendToNonExistentClient() + { + int port = GetNextPort(); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + try + { + await Assert.ThrowsAsync(() => server.SendAsync(Guid.NewGuid(), "Hello")); + } + finally + { + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task ConnectToNonExistentServer() + { + var client = new WatsonTcpClient("10.1.2.3", 1234); + SetupDefaultClientHandlers(client); + client.Settings.ConnectTimeoutSeconds = 2; + await Task.Delay(50); + + try + { + Assert.ThrowsAny(() => client.Connect()); + } + finally + { + SafeDispose(client); + } + } + + #endregion + + #region Concurrent-Tests + + [Fact] + public async Task ConcurrentClientConnections() + { + int port = GetNextPort(); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + var clients = new List(); + try + { + for (int i = 0; i < 5; i++) + { + var c = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(c); + c.Connect(); + clients.Add(c); + await Task.Delay(100); + } + await Task.Delay(500); + Assert.Equal(5, server.ListClients().Count()); + } + finally + { + foreach (var c in clients) SafeDispose(c); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task ConcurrentMessageSends() + { + int port = GetNextPort(); + int count = 0; + var allReceived = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Events.MessageReceived += (s, e) => { if (Interlocked.Increment(ref count) >= 10) allReceived.Set(); }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + var tasks = Enumerable.Range(0, 10).Select(i => client.SendAsync("Msg " + i)).ToArray(); + await Task.WhenAll(tasks); + Assert.True(allReceived.WaitOne(10000)); + Assert.Equal(10, count); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region Client-GUID-Tests + + [Fact] + public async Task SpecifyClientGuid() + { + int port = GetNextPort(); + Guid customGuid = Guid.Parse("11111111-2222-3333-4444-555555555555"); + Guid? serverSawGuid = null; + var connectedEvent = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Events.ClientConnected += (s, e) => { serverSawGuid = e.Client.Guid; connectedEvent.Set(); }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Settings.Guid = customGuid; + client.Connect(); + + try + { + Assert.True(connectedEvent.WaitOne(5000)); + Assert.Equal(customGuid, serverSawGuid); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region Idle-Timeout-Tests + + [Fact] + public async Task IdleClientTimeout() + { + int port = GetNextPort(); + bool timedOut = false; + var disconnectEvent = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Settings.IdleClientTimeoutSeconds = 3; + server.Events.ClientDisconnected += (s, e) => + { + if (e.Reason == DisconnectReason.Timeout) timedOut = true; + disconnectEvent.Set(); + }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + Assert.True(disconnectEvent.WaitOne(15000)); + Assert.True(timedOut); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region Authentication-Tests + + [Fact] + public async Task AuthenticationSuccess() + { + int port = GetNextPort(); + string presharedKey = "0000000000000000"; + bool authSucceeded = false; + var authEvent = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + server.Events.MessageReceived += (s, e) => { }; + server.Settings.PresharedKey = presharedKey; + server.Events.AuthenticationSucceeded += (s, e) => { authSucceeded = true; authEvent.Set(); }; + server.Start(); + await Task.Delay(200); + + var client = new WatsonTcpClient(_hostname, port); + client.Events.MessageReceived += (s, e) => { }; + client.Settings.PresharedKey = presharedKey; + client.Connect(); + await Task.Delay(500); + + try + { + Assert.True(authEvent.WaitOne(5000)); + Assert.True(authSucceeded); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task AuthenticationFailure() + { + int port = GetNextPort(); + bool authFailed = false; + var authEvent = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + server.Events.MessageReceived += (s, e) => { }; + server.Settings.PresharedKey = "correctkey123456"; + server.Events.AuthenticationFailed += (s, e) => { authFailed = true; authEvent.Set(); }; + server.Start(); + await Task.Delay(200); + + var client = new WatsonTcpClient(_hostname, port); + client.Events.MessageReceived += (s, e) => { }; + client.Settings.PresharedKey = "wrongkey12345678"; + client.Connect(); + + try + { + Assert.True(authEvent.WaitOne(5000)); + Assert.True(authFailed); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task AuthenticationCallback() + { + int port = GetNextPort(); + string presharedKey = "callback12345678"; + bool authSucceeded = false; + bool callbackCalled = false; + var authEvent = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + server.Events.MessageReceived += (s, e) => { }; + server.Settings.PresharedKey = presharedKey; + server.Events.AuthenticationSucceeded += (s, e) => { authSucceeded = true; authEvent.Set(); }; + server.Start(); + await Task.Delay(200); + + var client = new WatsonTcpClient(_hostname, port); + client.Events.MessageReceived += (s, e) => { }; + client.Callbacks.AuthenticationRequested = () => { callbackCalled = true; return presharedKey; }; + client.Connect(); + await Task.Delay(1000); + + try + { + Assert.True(authEvent.WaitOne(5000)); + Assert.True(authSucceeded); + Assert.True(callbackCalled); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region Throughput-Tests + + [Fact] + public async Task ThroughputSmallMessages() + { + await RunThroughputTest(messageSize: 64, messageCount: 5000); + } + + [Fact] + public async Task ThroughputMediumMessages() + { + await RunThroughputTest(messageSize: 65536, messageCount: 500); + } + + [Fact] + public async Task ThroughputLargeMessages() + { + await RunThroughputTest(messageSize: 4 * 1024 * 1024, messageCount: 20); + } + + private async Task RunThroughputTest(int messageSize, int messageCount) + { + int port = GetNextPort(); + int receivedCount = 0; + var allReceived = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Events.MessageReceived += (s, e) => + { + if (Interlocked.Increment(ref receivedCount) >= messageCount) + allReceived.Set(); + }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + byte[] data = new byte[messageSize]; + new Random(42).NextBytes(data); + + for (int i = 0; i < messageCount; i++) + { + bool sent = await client.SendAsync(data); + Assert.True(sent, $"Send failed at message {i}"); + } + + int timeoutMs = Math.Max(30000, messageCount * 100); + Assert.True(allReceived.WaitOne(timeoutMs), $"Only {receivedCount}/{messageCount} messages received within timeout"); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region v6.1.0-MaxConnections-Tests + + [Fact] + public async Task MaxConnectionsEnforced() + { + int port = GetNextPort(); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Settings.MaxConnections = 2; + server.Settings.EnforceMaxConnections = true; + server.Start(); + await Task.Delay(100); + + var clients = new List(); + WatsonTcpClient thirdClient = null; + try + { + for (int i = 0; i < 2; i++) + { + var c = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(c); + c.Connect(); + clients.Add(c); + await Task.Delay(200); + } + Assert.Equal(2, server.Connections); + + bool rejected = false; + try + { + thirdClient = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(thirdClient); + thirdClient.Connect(); + await Task.Delay(500); + if (server.Connections <= 2) rejected = true; + } + catch { rejected = true; } + + Assert.True(rejected); + } + finally + { + SafeDispose(thirdClient); + foreach (var c in clients) SafeDispose(c); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task MaxConnectionsNotEnforced() + { + int port = GetNextPort(); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Settings.MaxConnections = 2; + server.Settings.EnforceMaxConnections = false; + server.Start(); + await Task.Delay(100); + + var clients = new List(); + try + { + for (int i = 0; i < 3; i++) + { + var c = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(c); + c.Connect(); + clients.Add(c); + await Task.Delay(200); + } + await Task.Delay(500); + Assert.True(server.Connections >= 3); + } + finally + { + foreach (var c in clients) SafeDispose(c); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region v6.1.0-MaxHeaderSize-Tests + + [Fact] + public void MaxHeaderSizeSetting() + { + var serverSettings = new WatsonTcpServerSettings(); + serverSettings.MaxHeaderSize = 1024; + Assert.Equal(1024, serverSettings.MaxHeaderSize); + + var clientSettings = new WatsonTcpClientSettings(); + clientSettings.MaxHeaderSize = 2048; + Assert.Equal(2048, clientSettings.MaxHeaderSize); + + Assert.Throws(() => serverSettings.MaxHeaderSize = 10); + } + + #endregion + + #region v6.1.0-Rapid-Connect-Disconnect + + [Fact] + public async Task RapidConnectDisconnect() + { + int port = GetNextPort(); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + try + { + for (int i = 0; i < 10; i++) + { + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(50); + SafeDispose(client); + await Task.Delay(50); + } + await Task.Delay(500); + Assert.True(server.IsListening); + } + finally + { + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region v6.1.0-Concurrent-Sync-Requests + + [Fact] + public async Task ConcurrentSyncRequests() + { + int port = GetNextPort(); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Callbacks.SyncRequestReceivedAsync = async (req) => + { + string data = Encoding.UTF8.GetString(req.Data); + await Task.Delay(50); + return new SyncResponse(req, "Reply:" + data); + }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + var tasks = Enumerable.Range(0, 5) + .Select(i => client.SendAndWaitAsync(10000, "Request" + i)) + .ToArray(); + var responses = await Task.WhenAll(tasks); + + var expected = Enumerable.Range(0, 5).Select(i => "Reply:Request" + i).ToHashSet(); + var actual = responses.Select(r => Encoding.UTF8.GetString(r.Data)).ToHashSet(); + Assert.True(expected.SetEquals(actual)); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region v6.1.0-SSL-Tests + + [Fact] + public async Task SslConnectivity() + { + string pfxFile = "test.pfx"; + if (!File.Exists(pfxFile)) + { + // Skip if no certificate available + return; + } + + int port = GetNextPort(); + var server = new WatsonTcpServer(_hostname, port, pfxFile, "password"); + SetupDefaultServerHandlers(server); + server.Settings.AcceptInvalidCertificates = true; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port, pfxFile, "password"); + SetupDefaultClientHandlers(client); + client.Settings.AcceptInvalidCertificates = true; + client.Connect(); + await Task.Delay(200); + + try + { + Assert.True(client.Connected); + Assert.Single(server.ListClients()); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task SslMessageExchange() + { + string pfxFile = "test.pfx"; + if (!File.Exists(pfxFile)) return; + + int port = GetNextPort(); + string receivedData = null; + var messageReceived = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port, pfxFile, "password"); + server.Settings.AcceptInvalidCertificates = true; + server.Events.MessageReceived += (s, e) => { receivedData = Encoding.UTF8.GetString(e.Data); messageReceived.Set(); }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port, pfxFile, "password"); + SetupDefaultClientHandlers(client); + client.Settings.AcceptInvalidCertificates = true; + client.Connect(); + await Task.Delay(200); + + try + { + await client.SendAsync("Hello over SSL!"); + Assert.True(messageReceived.WaitOne(5000)); + Assert.Equal("Hello over SSL!", receivedData); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + + #region v6.1.0-Edge-Cases + + [Fact] + public async Task DuplicateClientGuid() + { + int port = GetNextPort(); + var server = new WatsonTcpServer(_hostname, port); + SetupDefaultServerHandlers(server); + server.Start(); + await Task.Delay(100); + + Guid sharedGuid = Guid.NewGuid(); + var client1 = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client1); + client1.Settings.Guid = sharedGuid; + client1.Connect(); + await Task.Delay(300); + + var client2 = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client2); + client2.Settings.Guid = sharedGuid; + client2.Connect(); + await Task.Delay(500); + + try + { + Assert.True(client1.Connected || client2.Connected); + } + finally + { + SafeDispose(client1); + SafeDispose(client2); + SafeDispose(server); + await Task.Delay(100); + } + } + + [Fact] + public async Task SendWithOffset() + { + int port = GetNextPort(); + byte[] receivedBytes = null; + var messageReceived = new ManualResetEvent(false); + + var server = new WatsonTcpServer(_hostname, port); + server.Events.MessageReceived += (s, e) => { receivedBytes = e.Data; messageReceived.Set(); }; + server.Start(); + await Task.Delay(100); + + var client = new WatsonTcpClient(_hostname, port); + SetupDefaultClientHandlers(client); + client.Connect(); + await Task.Delay(200); + + try + { + byte[] fullData = Encoding.UTF8.GetBytes("HEADERHello World"); + await client.SendAsync(fullData, null, 6); // skip "HEADER" + Assert.True(messageReceived.WaitOne(5000)); + Assert.Equal("Hello World", Encoding.UTF8.GetString(receivedBytes)); + } + finally + { + SafeDispose(client); + SafeDispose(server); + await Task.Delay(100); + } + } + + #endregion + } +} diff --git a/src/Test.XUnit/xunit.runner.json b/src/Test.XUnit/xunit.runner.json new file mode 100644 index 0000000..08c512b --- /dev/null +++ b/src/Test.XUnit/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeTestCollections": false +} diff --git a/src/WatsonTcp.sln b/src/WatsonTcp.sln index c4208d0..6f32cf8 100644 --- a/src/WatsonTcp.sln +++ b/src/WatsonTcp.sln @@ -49,6 +49,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test.FileTransfer", "Test.F EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test.Automated", "Test.Automated\Test.Automated.csproj", "{BA9BC15D-FCEC-4978-B960-A4474771E2D2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test.XUnit", "Test.XUnit\Test.XUnit.csproj", "{DD68C652-281A-42B3-9861-55735E2D91A7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -335,6 +337,18 @@ Global {BA9BC15D-FCEC-4978-B960-A4474771E2D2}.Release|x64.Build.0 = Release|Any CPU {BA9BC15D-FCEC-4978-B960-A4474771E2D2}.Release|x86.ActiveCfg = Release|Any CPU {BA9BC15D-FCEC-4978-B960-A4474771E2D2}.Release|x86.Build.0 = Release|Any CPU + {DD68C652-281A-42B3-9861-55735E2D91A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DD68C652-281A-42B3-9861-55735E2D91A7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DD68C652-281A-42B3-9861-55735E2D91A7}.Debug|x64.ActiveCfg = Debug|Any CPU + {DD68C652-281A-42B3-9861-55735E2D91A7}.Debug|x64.Build.0 = Debug|Any CPU + {DD68C652-281A-42B3-9861-55735E2D91A7}.Debug|x86.ActiveCfg = Debug|Any CPU + {DD68C652-281A-42B3-9861-55735E2D91A7}.Debug|x86.Build.0 = Debug|Any CPU + {DD68C652-281A-42B3-9861-55735E2D91A7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DD68C652-281A-42B3-9861-55735E2D91A7}.Release|Any CPU.Build.0 = Release|Any CPU + {DD68C652-281A-42B3-9861-55735E2D91A7}.Release|x64.ActiveCfg = Release|Any CPU + {DD68C652-281A-42B3-9861-55735E2D91A7}.Release|x64.Build.0 = Release|Any CPU + {DD68C652-281A-42B3-9861-55735E2D91A7}.Release|x86.ActiveCfg = Release|Any CPU + {DD68C652-281A-42B3-9861-55735E2D91A7}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/WatsonTcp/ClientMetadata.cs b/src/WatsonTcp/ClientMetadata.cs index 0c08f5a..f867e90 100644 --- a/src/WatsonTcp/ClientMetadata.cs +++ b/src/WatsonTcp/ClientMetadata.cs @@ -134,6 +134,7 @@ internal ClientMetadata(TcpClient tcp) /// public void Dispose() { + GC.SuppressFinalize(this); if (TokenSource != null) { if (!TokenSource.IsCancellationRequested) @@ -152,9 +153,19 @@ public void Dispose() _TcpClient.Dispose(); } - while (DataReceiver?.Status == TaskStatus.Running) + if (DataReceiver != null) { - Task.Delay(30).Wait(); + try + { + DataReceiver.Wait(TimeSpan.FromSeconds(5)); + } + catch (AggregateException) + { + // Task may have been cancelled + } + catch (ObjectDisposedException) + { + } } } diff --git a/src/WatsonTcp/ClientMetadataManager.cs b/src/WatsonTcp/ClientMetadataManager.cs index f9e615d..90ce44d 100644 --- a/src/WatsonTcp/ClientMetadataManager.cs +++ b/src/WatsonTcp/ClientMetadataManager.cs @@ -1,715 +1,519 @@ -namespace WatsonTcp -{ - using System; - using System.Collections.Generic; - using System.Threading; - - internal class ClientMetadataManager : IDisposable - { - #region Internal-Members - - #endregion - - #region Private-Members - - private readonly ReaderWriterLockSlim _UnauthenticatedClientsLock = new ReaderWriterLockSlim(); - private Dictionary _UnauthenticatedClients = new Dictionary(); - - private readonly ReaderWriterLockSlim _ClientsLock = new ReaderWriterLockSlim(); - private Dictionary _Clients = new Dictionary(); - - private readonly ReaderWriterLockSlim _ClientsLastSeenLock = new ReaderWriterLockSlim(); - private Dictionary _ClientsLastSeen = new Dictionary(); - - private readonly ReaderWriterLockSlim _ClientsKickedLock = new ReaderWriterLockSlim(); - private Dictionary _ClientsKicked = new Dictionary(); - - private readonly ReaderWriterLockSlim _ClientsTimedoutLock = new ReaderWriterLockSlim(); - private Dictionary _ClientsTimedout = new Dictionary(); - - #endregion - - #region Constructors-and-Factories - - internal ClientMetadataManager() - { - - } - - #endregion - - #region Public-Methods - - /// - /// Dispose. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Dispose. - /// - /// Indicate if resources should be disposed. - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - _UnauthenticatedClients = null; - _Clients = null; - _ClientsLastSeen = null; - _ClientsKicked = null; - _ClientsTimedout = null; - } - } - - #endregion - - #region Internal-Methods - - internal void Reset() - { - - } - - internal void ReplaceGuid(Guid original, Guid replace) - { - ReplaceUnauthenticatedClient(original, replace); - ReplaceClient(original, replace); - ReplaceClientLastSeen(original, replace); - ReplaceClientKicked(original, replace); - ReplaceClientTimedout(original, replace); - } - - internal void Remove(Guid guid) - { - RemoveUnauthenticatedClient(guid); - RemoveClient(guid); - RemoveClientLastSeen(guid); - RemoveClientKicked(guid); - RemoveClientTimedout(guid); - } - - /* - - private ConcurrentDictionary _UnauthenticatedClients = new ConcurrentDictionary(); - private ConcurrentDictionary _Clients = new ConcurrentDictionary(); - private ConcurrentDictionary _ClientsLastSeen = new ConcurrentDictionary(); - private ConcurrentDictionary _ClientsKicked = new ConcurrentDictionary(); - private ConcurrentDictionary _ClientsTimedout = new ConcurrentDictionary(); - - */ - - #region Unauthenticated-Clients - - #region Helpers - private void _addUnauthenticatedClient(Guid guid, DateTime? dt = null) - { - _UnauthenticatedClientsLock.EnterWriteLock(); - - try - { - if (dt == null) - { - _UnauthenticatedClients.Add(guid, DateTime.UtcNow); - } - else - { - _UnauthenticatedClients.Add(guid, dt.Value); - } - } - finally - { - _UnauthenticatedClientsLock.ExitWriteLock(); - } - - } - - private void _removeUnauthenticatedClient(Guid guid) - { - if (_existsUnauthenticatedClient(guid)) - { - _UnauthenticatedClientsLock.EnterWriteLock(); - - try - { - _UnauthenticatedClients.Remove(guid); - } - finally - { - _UnauthenticatedClientsLock.ExitWriteLock(); - } - - - } - } - - private bool _existsUnauthenticatedClient(Guid guid) - { - _UnauthenticatedClientsLock.EnterReadLock(); - - try - { - return _UnauthenticatedClients.ContainsKey(guid); - } - finally - { - _UnauthenticatedClientsLock.ExitReadLock(); - } - } - - private DateTime _unauthenticatedClientsGetDateTime(Guid guid) - { - _UnauthenticatedClientsLock.EnterReadLock(); - - try - { - return _UnauthenticatedClients[guid]; - } - finally - { - _UnauthenticatedClientsLock.ExitReadLock(); - } - - } - #endregion - - internal void AddUnauthenticatedClient(Guid guid) => _addUnauthenticatedClient(guid); - - internal void RemoveUnauthenticatedClient(Guid guid) => _removeUnauthenticatedClient(guid); - - - internal bool ExistsUnauthenticatedClient(Guid guid) => _existsUnauthenticatedClient(guid); - - - internal void ReplaceUnauthenticatedClient(Guid original, Guid update) - { - - if (_existsUnauthenticatedClient(original)) - { - DateTime dt = _unauthenticatedClientsGetDateTime(original); - _removeUnauthenticatedClient(original); - _addUnauthenticatedClient(update, dt); - } - - } - - internal Dictionary AllUnauthenticatedClients() - { - - _UnauthenticatedClientsLock.EnterReadLock(); - - try - { - return new Dictionary(_UnauthenticatedClients); - } - finally - { - _UnauthenticatedClientsLock.ExitReadLock(); - } - - } - - #endregion - - - - #region Clients - - - #region Helpers - - private void _addClient(Guid guid, ClientMetadata client) - { - _ClientsLock.EnterWriteLock(); - - try - { - - _Clients.Add(guid, client); - - } - finally - { - _ClientsLock.ExitWriteLock(); - } - - } - - private void _removeClient(Guid guid) - { - if (_existsClient(guid)) - { - _ClientsLock.EnterWriteLock(); - - try - { - _Clients.Remove(guid); - } - finally - { - _ClientsLock.ExitWriteLock(); - } - - - } - } - - private bool _existsClient(Guid guid) - { - _ClientsLock.EnterReadLock(); - - try - { - return _Clients.ContainsKey(guid); - } - finally - { - _ClientsLock.ExitReadLock(); - } - } - - private ClientMetadata _getClientMetadata(Guid guid) - { - _ClientsLock.EnterReadLock(); - - try - { - return _Clients[guid]; - } - finally - { - _ClientsLock.ExitReadLock(); - } - - } - - - #endregion - - internal void AddClient(Guid guid, ClientMetadata client) => _addClient(guid, client); - - internal ClientMetadata GetClient(Guid guid) => _existsClient(guid) ? _Clients[guid] : null; - - internal void RemoveClient(Guid guid) => _removeClient(guid); - - internal bool ExistsClient(Guid guid) => _existsClient(guid); - - internal void ReplaceClient(Guid original, Guid update) - { - - if (_existsClient(original)) - { - ClientMetadata md = _getClientMetadata(original); - _removeClient(original); - _addClient(update, md); - } - - } - - internal Dictionary AllClients() - { - - _ClientsLock.EnterReadLock(); - - try - { - return new Dictionary(_Clients); - } - finally - { - _ClientsLock.ExitReadLock(); - } - } - - #endregion - - - - #region Clients-Last-Seen - - - #region Helpers - private void _addClientLastSeen(Guid guid, DateTime? dt = null) - { - if (_existsClientLastSeen(guid)) return; - - _ClientsLastSeenLock.EnterWriteLock(); - - try - { - if (dt == null) - { - _ClientsLastSeen.Add(guid, DateTime.UtcNow); - } - else - { - _ClientsLastSeen.Add(guid, dt.Value); - } - } - finally - { - _ClientsLastSeenLock.ExitWriteLock(); - } - - } - - private void _removeClientLastSeen(Guid guid) - { - if (!_existsClientLastSeen(guid)) return; - - _ClientsLastSeenLock.EnterWriteLock(); - - try - { - _ClientsLastSeen.Remove(guid); - } - finally - { - _ClientsLastSeenLock.ExitWriteLock(); - } - - - - } - - private bool _existsClientLastSeen(Guid guid) - { - _ClientsLastSeenLock.EnterReadLock(); - - try - { - return _ClientsLastSeen.ContainsKey(guid); - } - finally - { - _ClientsLastSeenLock.ExitReadLock(); - } - } - - private DateTime _clientLastSeenGetDateTime(Guid guid) - { - _ClientsLastSeenLock.EnterReadLock(); - - try - { - return _ClientsLastSeen[guid]; - } - finally - { - _ClientsLastSeenLock.ExitReadLock(); - } - - } - #endregion - - internal void AddClientLastSeen(Guid guid) => _addClientLastSeen(guid); - - internal void RemoveClientLastSeen(Guid guid) => _removeClientLastSeen(guid); - - internal bool ExistsClientLastSeen(Guid guid) => _existsClientLastSeen(guid); - - internal void ReplaceClientLastSeen(Guid original, Guid update) - { - - if (_existsClientLastSeen(original)) - { - DateTime dt = _clientLastSeenGetDateTime(original); - _removeClientLastSeen(original); - _addClientLastSeen(update, dt); - } - - } - - internal void UpdateClientLastSeen(Guid guid, DateTime dt) - { - - if (_existsClientLastSeen(guid)) - { - _removeClientLastSeen(guid); - _addClientLastSeen(guid, dt.ToUniversalTime()); - } - - } - - internal Dictionary AllClientsLastSeen() - { - - _ClientsLastSeenLock.EnterReadLock(); - - try - { - return new Dictionary(_ClientsLastSeen); - } - finally - { - _ClientsLastSeenLock.ExitReadLock(); - } - - - } - - #endregion - - - - - #region Clients-Kicked - - - #region Helpers - private void _addClientKicked(Guid guid, DateTime? dt = null) - { - if (_existsClientKicked(guid)) return; - - _ClientsKickedLock.EnterWriteLock(); - - try - { - if (dt == null) - { - _ClientsKicked.Add(guid, DateTime.UtcNow); - } - else - { - _ClientsKicked.Add(guid, dt.Value); - } - } - finally - { - _ClientsKickedLock.ExitWriteLock(); - } - - } - - private void _removeClientKicked(Guid guid) - { - if (!_existsClientKicked(guid)) return; - - _ClientsKickedLock.EnterWriteLock(); - - try - { - _ClientsKicked.Remove(guid); - } - finally - { - _ClientsKickedLock.ExitWriteLock(); - } - - - - } - - private bool _existsClientKicked(Guid guid) - { - _ClientsKickedLock.EnterReadLock(); - - try - { - return _ClientsKicked.ContainsKey(guid); - } - finally - { - _ClientsKickedLock.ExitReadLock(); - } - } - - private DateTime _clientKickedGetDateTime(Guid guid) - { - _ClientsKickedLock.EnterReadLock(); - - try - { - return _ClientsKicked[guid]; - } - finally - { - _ClientsKickedLock.ExitReadLock(); - } - - } - #endregion - - - - internal void AddClientKicked(Guid guid) => _addClientKicked(guid); - - internal void RemoveClientKicked(Guid guid) => _removeClientKicked(guid); - - internal bool ExistsClientKicked(Guid guid) => _existsClientKicked(guid); - - internal void ReplaceClientKicked(Guid original, Guid update) - { - - if (_existsClientKicked(original)) - { - DateTime dt = _clientKickedGetDateTime(original); - _removeClientKicked(original); - _addClientKicked(update, dt); - } - - } - - internal void UpdateClientKicked(Guid guid, DateTime dt) - { - - if (_existsClientKicked(guid)) - { - _removeClientKicked(guid); - _addClientKicked(guid, dt.ToUniversalTime()); - } - - } - - internal Dictionary AllClientsKicked() - { - - _ClientsKickedLock.EnterReadLock(); - - try - { - return new Dictionary(_ClientsKicked); - } - finally - { - _ClientsKickedLock.ExitReadLock(); - } - - } - - #endregion - - - - #region Clients-Timedout - - - #region Helpers - private void _addClientTimedout(Guid guid, DateTime? dt = null) - { - if (_existsClientTimedout(guid)) return; - - _ClientsTimedoutLock.EnterWriteLock(); - - try - { - if (dt == null) - { - _ClientsTimedout.Add(guid, DateTime.UtcNow); - } - else - { - _ClientsTimedout.Add(guid, dt.Value); - } - } - finally - { - _ClientsTimedoutLock.ExitWriteLock(); - } - - } - - private void _removeClientTimedout(Guid guid) - { - if (!_existsClientTimedout(guid)) return; - - _ClientsTimedoutLock.EnterWriteLock(); - - try - { - _ClientsTimedout.Remove(guid); - } - finally - { - _ClientsTimedoutLock.ExitWriteLock(); - } - - - - } - - private bool _existsClientTimedout(Guid guid) - { - _ClientsTimedoutLock.EnterReadLock(); - - try - { - return _ClientsTimedout.ContainsKey(guid); - } - finally - { - _ClientsTimedoutLock.ExitReadLock(); - } - } - - private DateTime _clientTimeoutGetDateTime(Guid guid) - { - _ClientsTimedoutLock.EnterReadLock(); - - try - { - return _ClientsTimedout[guid]; - } - finally - { - _ClientsTimedoutLock.ExitReadLock(); - } - - } - #endregion - - internal void AddClientTimedout(Guid guid) => _addClientTimedout(guid); - - internal void RemoveClientTimedout(Guid guid) => _removeClientTimedout(guid); - - internal bool ExistsClientTimedout(Guid guid) => _existsClientTimedout(guid); - - internal void ReplaceClientTimedout(Guid original, Guid update) - { - - if (_existsClientTimedout(original)) - { - DateTime dt = _clientTimeoutGetDateTime(original); - _removeClientTimedout(original); - _addClientTimedout(update, dt); - } - - } - - internal void UpdateClientTimeout(Guid guid, DateTime dt) - { - if (_existsClientTimedout(guid)) - { - _removeClientTimedout(guid); - _addClientTimedout(guid, dt.ToUniversalTime()); - } - - } - - internal Dictionary AllClientsTimedout() - { - _ClientsTimedoutLock.EnterReadLock(); - - try - { - return new Dictionary(_ClientsTimedout); - } - finally - { - _ClientsTimedoutLock.ExitReadLock(); - } - } - - #endregion - - - #endregion - - #region Private-Methods - - #endregion - } -} +namespace WatsonTcp +{ + using System; + using System.Collections.Generic; + using System.Threading; + + internal sealed class ClientMetadataManager : IDisposable + { + #region Internal-Members + + #endregion + + #region Private-Members + + private readonly ReaderWriterLockSlim _Lock = new ReaderWriterLockSlim(); + private Dictionary _UnauthenticatedClients = new Dictionary(); + private Dictionary _Clients = new Dictionary(); + private Dictionary _ClientsLastSeen = new Dictionary(); + private Dictionary _ClientsKicked = new Dictionary(); + private Dictionary _ClientsTimedout = new Dictionary(); + + #endregion + + #region Constructors-and-Factories + + internal ClientMetadataManager() + { + + } + + #endregion + + #region Public-Methods + + /// + /// Dispose. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Dispose. + /// + /// Indicate if resources should be disposed. + private void Dispose(bool disposing) + { + if (disposing) + { + _Lock.EnterWriteLock(); + try + { + _UnauthenticatedClients = null; + _Clients = null; + _ClientsLastSeen = null; + _ClientsKicked = null; + _ClientsTimedout = null; + } + finally + { + _Lock.ExitWriteLock(); + } + } + } + + #endregion + + #region Internal-Methods + + internal static void Reset() + { + + } + + internal void ReplaceGuid(Guid original, Guid replace) + { + _Lock.EnterWriteLock(); + try + { + // Unauthenticated clients + DateTime dt; + if (_UnauthenticatedClients.TryGetValue(original, out dt)) + { + _UnauthenticatedClients.Remove(original); + _UnauthenticatedClients[replace] = dt; + } + + // Clients + ClientMetadata md; + if (_Clients.TryGetValue(original, out md)) + { + _Clients.Remove(original); + _Clients[replace] = md; + } + + // Last seen + if (_ClientsLastSeen.TryGetValue(original, out dt)) + { + _ClientsLastSeen.Remove(original); + _ClientsLastSeen[replace] = dt; + } + + // Kicked + if (_ClientsKicked.TryGetValue(original, out dt)) + { + _ClientsKicked.Remove(original); + _ClientsKicked[replace] = dt; + } + + // Timed out + if (_ClientsTimedout.TryGetValue(original, out dt)) + { + _ClientsTimedout.Remove(original); + _ClientsTimedout[replace] = dt; + } + } + finally + { + _Lock.ExitWriteLock(); + } + } + + internal void Remove(Guid guid) + { + _Lock.EnterWriteLock(); + try + { + _UnauthenticatedClients.Remove(guid); + _Clients.Remove(guid); + _ClientsLastSeen.Remove(guid); + _ClientsKicked.Remove(guid); + _ClientsTimedout.Remove(guid); + } + finally + { + _Lock.ExitWriteLock(); + } + } + + /// + /// Purge stale kicked and timed-out client records older than the specified age. + /// + /// Maximum age of records to keep. + internal void PurgeStaleRecords(TimeSpan maxAge) + { + DateTime cutoff = DateTime.UtcNow - maxAge; + List toRemoveKicked = new List(); + List toRemoveTimedout = new List(); + + _Lock.EnterWriteLock(); + try + { + foreach (var kvp in _ClientsKicked) + { + if (kvp.Value < cutoff && !_Clients.ContainsKey(kvp.Key)) + { + toRemoveKicked.Add(kvp.Key); + } + } + + foreach (var kvp in _ClientsTimedout) + { + if (kvp.Value < cutoff && !_Clients.ContainsKey(kvp.Key)) + { + toRemoveTimedout.Add(kvp.Key); + } + } + + foreach (Guid guid in toRemoveKicked) + { + _ClientsKicked.Remove(guid); + } + + foreach (Guid guid in toRemoveTimedout) + { + _ClientsTimedout.Remove(guid); + } + } + finally + { + _Lock.ExitWriteLock(); + } + } + + #region Unauthenticated-Clients + + internal void AddUnauthenticatedClient(Guid guid) + { + _Lock.EnterWriteLock(); + try + { + _UnauthenticatedClients[guid] = DateTime.UtcNow; + } + finally + { + _Lock.ExitWriteLock(); + } + } + + internal void RemoveUnauthenticatedClient(Guid guid) + { + _Lock.EnterWriteLock(); + try + { + _UnauthenticatedClients.Remove(guid); + } + finally + { + _Lock.ExitWriteLock(); + } + } + + internal bool ExistsUnauthenticatedClient(Guid guid) + { + _Lock.EnterReadLock(); + try + { + return _UnauthenticatedClients.ContainsKey(guid); + } + finally + { + _Lock.ExitReadLock(); + } + } + + internal Dictionary AllUnauthenticatedClients() + { + _Lock.EnterReadLock(); + try + { + return new Dictionary(_UnauthenticatedClients); + } + finally + { + _Lock.ExitReadLock(); + } + } + + #endregion + + #region Clients + + internal void AddClient(Guid guid, ClientMetadata client) + { + _Lock.EnterWriteLock(); + try + { + _Clients[guid] = client; + } + finally + { + _Lock.ExitWriteLock(); + } + } + + internal ClientMetadata GetClient(Guid guid) + { + _Lock.EnterReadLock(); + try + { + ClientMetadata md; + if (_Clients.TryGetValue(guid, out md)) return md; + return null; + } + finally + { + _Lock.ExitReadLock(); + } + } + + internal void RemoveClient(Guid guid) + { + _Lock.EnterWriteLock(); + try + { + _Clients.Remove(guid); + } + finally + { + _Lock.ExitWriteLock(); + } + } + + internal bool ExistsClient(Guid guid) + { + _Lock.EnterReadLock(); + try + { + return _Clients.ContainsKey(guid); + } + finally + { + _Lock.ExitReadLock(); + } + } + + internal Dictionary AllClients() + { + _Lock.EnterReadLock(); + try + { + return new Dictionary(_Clients); + } + finally + { + _Lock.ExitReadLock(); + } + } + + internal int ClientCount() + { + _Lock.EnterReadLock(); + try + { + return _Clients.Count; + } + finally + { + _Lock.ExitReadLock(); + } + } + + #endregion + + #region Clients-Last-Seen + + internal void AddClientLastSeen(Guid guid) + { + _Lock.EnterWriteLock(); + try + { + _ClientsLastSeen[guid] = DateTime.UtcNow; + } + finally + { + _Lock.ExitWriteLock(); + } + } + + internal void RemoveClientLastSeen(Guid guid) + { + _Lock.EnterWriteLock(); + try + { + _ClientsLastSeen.Remove(guid); + } + finally + { + _Lock.ExitWriteLock(); + } + } + + internal bool ExistsClientLastSeen(Guid guid) + { + _Lock.EnterReadLock(); + try + { + return _ClientsLastSeen.ContainsKey(guid); + } + finally + { + _Lock.ExitReadLock(); + } + } + + internal void UpdateClientLastSeen(Guid guid, DateTime dt) + { + _Lock.EnterWriteLock(); + try + { + if (_ClientsLastSeen.ContainsKey(guid)) + { + _ClientsLastSeen[guid] = dt.ToUniversalTime(); + } + } + finally + { + _Lock.ExitWriteLock(); + } + } + + internal Dictionary AllClientsLastSeen() + { + _Lock.EnterReadLock(); + try + { + return new Dictionary(_ClientsLastSeen); + } + finally + { + _Lock.ExitReadLock(); + } + } + + #endregion + + #region Clients-Kicked + + internal void AddClientKicked(Guid guid) + { + _Lock.EnterWriteLock(); + try + { + if (!_ClientsKicked.ContainsKey(guid)) + _ClientsKicked[guid] = DateTime.UtcNow; + } + finally + { + _Lock.ExitWriteLock(); + } + } + + internal void RemoveClientKicked(Guid guid) + { + _Lock.EnterWriteLock(); + try + { + _ClientsKicked.Remove(guid); + } + finally + { + _Lock.ExitWriteLock(); + } + } + + internal bool ExistsClientKicked(Guid guid) + { + _Lock.EnterReadLock(); + try + { + return _ClientsKicked.ContainsKey(guid); + } + finally + { + _Lock.ExitReadLock(); + } + } + + internal Dictionary AllClientsKicked() + { + _Lock.EnterReadLock(); + try + { + return new Dictionary(_ClientsKicked); + } + finally + { + _Lock.ExitReadLock(); + } + } + + #endregion + + #region Clients-Timedout + + internal void AddClientTimedout(Guid guid) + { + _Lock.EnterWriteLock(); + try + { + if (!_ClientsTimedout.ContainsKey(guid)) + _ClientsTimedout[guid] = DateTime.UtcNow; + } + finally + { + _Lock.ExitWriteLock(); + } + } + + internal void RemoveClientTimedout(Guid guid) + { + _Lock.EnterWriteLock(); + try + { + _ClientsTimedout.Remove(guid); + } + finally + { + _Lock.ExitWriteLock(); + } + } + + internal bool ExistsClientTimedout(Guid guid) + { + _Lock.EnterReadLock(); + try + { + return _ClientsTimedout.ContainsKey(guid); + } + finally + { + _Lock.ExitReadLock(); + } + } + + internal Dictionary AllClientsTimedout() + { + _Lock.EnterReadLock(); + try + { + return new Dictionary(_ClientsTimedout); + } + finally + { + _Lock.ExitReadLock(); + } + } + + #endregion + + #endregion + + #region Private-Methods + + #endregion + } +} diff --git a/src/WatsonTcp/DefaultSerializationHelper.cs b/src/WatsonTcp/DefaultSerializationHelper.cs index 86b9af9..31221e4 100644 --- a/src/WatsonTcp/DefaultSerializationHelper.cs +++ b/src/WatsonTcp/DefaultSerializationHelper.cs @@ -1,7 +1,6 @@ namespace WatsonTcp { using System; - using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; using System.Text.Json; @@ -100,7 +99,7 @@ public void InstantiateConverter() #region Private-Classes - private class ExceptionConverter : JsonConverter + private sealed class ExceptionConverter : JsonConverter { public override bool CanConvert(Type typeToConvert) { @@ -144,7 +143,7 @@ public override void Write(Utf8JsonWriter writer, TExceptionType value, JsonSeri } } - private class NameValueCollectionConverter : JsonConverter + private sealed class NameValueCollectionConverter : JsonConverter { public override NameValueCollection Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => throw new NotImplementedException(); diff --git a/src/WatsonTcp/SyncResponseReceivedEventArgs.cs b/src/WatsonTcp/SyncResponseReceivedEventArgs.cs index 309ee37..9a566f6 100644 --- a/src/WatsonTcp/SyncResponseReceivedEventArgs.cs +++ b/src/WatsonTcp/SyncResponseReceivedEventArgs.cs @@ -3,7 +3,7 @@ /// /// Internal EventArgs for passing arguments for SyncResponseReceived event. /// - internal class SyncResponseReceivedEventArgs + internal sealed class SyncResponseReceivedEventArgs { #region Public-Members diff --git a/src/WatsonTcp/WatsonMessageBuilder.cs b/src/WatsonTcp/WatsonMessageBuilder.cs index 7b3f990..ad1c707 100644 --- a/src/WatsonTcp/WatsonMessageBuilder.cs +++ b/src/WatsonTcp/WatsonMessageBuilder.cs @@ -1,189 +1,205 @@ -namespace WatsonTcp -{ - using System; - using System.Collections.Generic; - using System.IO; - using System.Linq; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - - internal class WatsonMessageBuilder - { - #region Internal-Members - - internal ISerializationHelper SerializationHelper - { - get => _SerializationHelper; - set - { - if (value == null) throw new ArgumentNullException(nameof(SerializationHelper)); - _SerializationHelper = value; - } - } - - internal int ReadStreamBuffer - { - get => _ReadStreamBuffer; - set - { - if (value < 1) throw new ArgumentOutOfRangeException(nameof(ReadStreamBuffer)); - _ReadStreamBuffer = value; - } - } - - #endregion - - #region Private-Members - - private ISerializationHelper _SerializationHelper = new DefaultSerializationHelper(); - private int _ReadStreamBuffer = 65536; - - #endregion - - #region Constructors-and-Factories - - internal WatsonMessageBuilder() - { - - } - - #endregion - - #region Internal-Methods - - /// - /// Construct a new message to send. - /// - /// The number of bytes included in the stream. - /// The stream containing the data. - /// Indicate if the message is a synchronous message request. - /// Indicate if the message is a synchronous message response. - /// The UTC time at which the message should expire (only valid for synchronous message requests). - /// Metadata to attach to the message. - internal WatsonMessage ConstructNew( - long contentLength, - Stream stream, - bool syncRequest = false, - bool syncResponse = false, - DateTime? expirationUtc = null, - Dictionary metadata = null) - { - if (contentLength < 0) throw new ArgumentException("Content length must be zero or greater."); - if (contentLength > 0) - { - if (stream == null || !stream.CanRead) - { - throw new ArgumentException("Cannot read from supplied stream."); - } - } - - WatsonMessage msg = new WatsonMessage(); - msg.ContentLength = contentLength; - msg.DataStream = stream; - msg.SyncRequest = syncRequest; - msg.SyncResponse = syncResponse; - msg.ExpirationUtc = expirationUtc; - msg.Metadata = metadata; - - return msg; - } - - /// - /// Read from a stream and construct a message. - /// - /// Stream. - /// Cancellation token. - internal async Task BuildFromStream(Stream stream, CancellationToken token = default) - { - if (stream == null) throw new ArgumentNullException(nameof(stream)); - if (!stream.CanRead) throw new ArgumentException("Cannot read from stream."); - - WatsonMessage msg = new WatsonMessage(); - - // {"len":0,"s":"Normal"}\r\n\r\n - byte[] headerBytes = new byte[24]; - byte[] headerBuffer = new byte[1]; - int read = 0; - int readTotal = 0; - - while (true) - { - #region Retrieve-First-24-Bytes - - read = await stream.ReadAsync(headerBytes, readTotal, (24 - readTotal), token).ConfigureAwait(false); - - if (read > 0) - { - readTotal += read; - if (readTotal >= 24) break; - } - else - { - return null; - } - - #endregion - } - - while (true) - { - #region Read-Byte-by-Byte - - byte[] endCheck = headerBytes.Skip(headerBytes.Length - 4).Take(4).ToArray(); - - if ((int)endCheck[3] == 0 - && (int)endCheck[2] == 0 - && (int)endCheck[1] == 0 - && (int)endCheck[0] == 0) - { - throw new IOException("Null header data indicates peer disconnected."); - } - - if ((int)endCheck[3] == 10 - && (int)endCheck[2] == 13 - && (int)endCheck[1] == 10 - && (int)endCheck[0] == 13) - { - // delimiter reached - break; - } - - read = await stream.ReadAsync(headerBuffer, 0, 1, token).ConfigureAwait(false); - if (read > 0) - headerBytes = WatsonCommon.AppendBytes(headerBytes, headerBuffer); - else - { - return null; - } - - #endregion - } - - msg = _SerializationHelper.DeserializeJson(Encoding.UTF8.GetString(headerBytes)); - msg.DataStream = stream; - - return msg; - } - - /// - /// Retrieve header bytes for a message. - /// - /// Watson message. - /// Header bytes. - internal byte[] GetHeaderBytes(WatsonMessage msg) - { - string jsonStr = _SerializationHelper.SerializeJson(msg, false); - byte[] jsonBytes = Encoding.UTF8.GetBytes(jsonStr); - byte[] end = Encoding.UTF8.GetBytes("\r\n\r\n"); - byte[] final = WatsonCommon.AppendBytes(jsonBytes, end); - return final; - } - - #endregion - - #region Private-Methods - - #endregion - } -} +namespace WatsonTcp +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + internal sealed class WatsonMessageBuilder + { + #region Internal-Members + + internal ISerializationHelper SerializationHelper + { + get => _SerializationHelper; + set + { + if (value == null) throw new ArgumentNullException(nameof(SerializationHelper)); + _SerializationHelper = value; + } + } + + internal int ReadStreamBuffer + { + get => _ReadStreamBuffer; + set + { + if (value < 1) throw new ArgumentOutOfRangeException(nameof(ReadStreamBuffer)); + _ReadStreamBuffer = value; + } + } + + internal int MaxHeaderSize + { + get => _MaxHeaderSize; + set + { + if (value < 25) throw new ArgumentOutOfRangeException(nameof(MaxHeaderSize)); + _MaxHeaderSize = value; + } + } + + #endregion + + #region Private-Members + + private ISerializationHelper _SerializationHelper = new DefaultSerializationHelper(); + private int _ReadStreamBuffer = 65536; + private int _MaxHeaderSize = 262144; + + #endregion + + #region Constructors-and-Factories + + internal WatsonMessageBuilder() + { + + } + + #endregion + + #region Internal-Methods + + /// + /// Construct a new message to send. + /// + /// The number of bytes included in the stream. + /// The stream containing the data. + /// Indicate if the message is a synchronous message request. + /// Indicate if the message is a synchronous message response. + /// The UTC time at which the message should expire (only valid for synchronous message requests). + /// Metadata to attach to the message. +#pragma warning disable CA1822 // Mark members as static - called as instance method via _MessageBuilder.ConstructNew(...) + internal WatsonMessage ConstructNew( + long contentLength, + Stream stream, + bool syncRequest = false, + bool syncResponse = false, + DateTime? expirationUtc = null, + Dictionary metadata = null) + { + if (contentLength < 0) throw new ArgumentException("Content length must be zero or greater."); + if (contentLength > 0) + { + if (stream == null || !stream.CanRead) + { + throw new ArgumentException("Cannot read from supplied stream."); + } + } + + WatsonMessage msg = new WatsonMessage(); + msg.ContentLength = contentLength; + msg.DataStream = stream; + msg.SyncRequest = syncRequest; + msg.SyncResponse = syncResponse; + msg.ExpirationUtc = expirationUtc; + msg.Metadata = metadata; + + return msg; + } +#pragma warning restore CA1822 + + /// + /// Read from a stream and construct a message. + /// + /// Stream. + /// Cancellation token. + internal async Task BuildFromStream(Stream stream, CancellationToken token = default) + { + if (stream == null) throw new ArgumentNullException(nameof(stream)); + if (!stream.CanRead) throw new ArgumentException("Cannot read from stream."); + + // Read header bytes until \r\n\r\n delimiter is found. + // Uses a MemoryStream accumulator instead of array concatenation, + // and direct byte comparison instead of LINQ, to avoid O(n^2) + // allocations and per-iteration LINQ overhead. + byte[] headerBuffer = new byte[1]; + int totalRead = 0; + + // Track the last 4 bytes for delimiter detection + // Initialize to non-matching values + byte prev3 = 0xFF, prev2 = 0xFF, prev1 = 0xFF, prev0 = 0xFF; + bool hasNonZero = false; + + using (MemoryStream headerStream = new MemoryStream(256)) + { + while (true) + { + int read = await stream.ReadAsync(headerBuffer, 0, 1, token).ConfigureAwait(false); + if (read <= 0) + { + return null; + } + + byte b = headerBuffer[0]; + headerStream.WriteByte(b); + totalRead++; + + if (b != 0) hasNonZero = true; + + // Shift the trailing 4-byte window + prev3 = prev2; + prev2 = prev1; + prev1 = prev0; + prev0 = b; + + // Check for null header (all zeros) at byte 4 + if (totalRead == 4 && !hasNonZero) + { + throw new IOException("Null header data indicates peer disconnected."); + } + + // Check for \r\n\r\n delimiter (13, 10, 13, 10) + if (totalRead >= 4 + && prev3 == 13 + && prev2 == 10 + && prev1 == 13 + && prev0 == 10) + { + break; + } + + // Enforce maximum header size + if (totalRead >= _MaxHeaderSize) + { + throw new IOException("Header size exceeds maximum allowed size of " + _MaxHeaderSize + " bytes."); + } + } + + // Return header bytes without the trailing \r\n\r\n delimiter + byte[] allBytes = headerStream.ToArray(); + int headerLength = allBytes.Length - 4; + + WatsonMessage msg = _SerializationHelper.DeserializeJson(Encoding.UTF8.GetString(allBytes, 0, headerLength)); + msg.DataStream = stream; + return msg; + } + } + + /// + /// Retrieve header bytes for a message. + /// + /// Watson message. + /// Header bytes. + internal byte[] GetHeaderBytes(WatsonMessage msg) + { + string jsonStr = _SerializationHelper.SerializeJson(msg, false); + byte[] jsonBytes = Encoding.UTF8.GetBytes(jsonStr); + byte[] result = new byte[jsonBytes.Length + 4]; + Buffer.BlockCopy(jsonBytes, 0, result, 0, jsonBytes.Length); + result[jsonBytes.Length] = 13; // \r + result[jsonBytes.Length + 1] = 10; // \n + result[jsonBytes.Length + 2] = 13; // \r + result[jsonBytes.Length + 3] = 10; // \n + return result; + } + + #endregion + + #region Private-Methods + + #endregion + } +} diff --git a/src/WatsonTcp/WatsonTcp.csproj b/src/WatsonTcp/WatsonTcp.csproj index 118fb10..7c4cadf 100644 --- a/src/WatsonTcp/WatsonTcp.csproj +++ b/src/WatsonTcp/WatsonTcp.csproj @@ -4,7 +4,10 @@ netstandard2.0;netstandard2.1;net462;net48;net8.0;net10.0 true true - 6.0.12 + true + latest-recommended + true + 6.1.0 Joel Christner Joel Christner A simple C# async TCP server and client with integrated framing for reliable transmission and receipt of data @@ -14,7 +17,7 @@ https://github.com/dotnet/WatsonTcp Github - Breaking changes, move towards async + Performance improvements, header parsing rewrite, connection management fixes, new settings LICENSE.md watson.png diff --git a/src/WatsonTcp/WatsonTcp.xml b/src/WatsonTcp/WatsonTcp.xml index 49e64b7..9fa8230 100644 --- a/src/WatsonTcp/WatsonTcp.xml +++ b/src/WatsonTcp/WatsonTcp.xml @@ -87,6 +87,12 @@ Indicate if resources should be disposed. + + + Purge stale kicked and timed-out client records older than the specified age. + + Maximum age of records to keep. + Event arguments for when a connection is established. @@ -1017,6 +1023,13 @@ Nagle's algorithm. Disable the delay when send or receive buffers are not full. If true, disable the delay. Default is true. + + + Maximum header size in bytes. Default is 262144 (256KB). + Headers larger than this value will be rejected to prevent memory exhaustion from malformed or malicious data. + Value must be greater than 24. + + Instantiate. @@ -1471,6 +1484,20 @@ Nagle's algorithm. Disable the delay when send or receive buffers are not full. If true, disable the delay. Default is true. + + + Maximum header size in bytes. Default is 262144 (256KB). + Headers larger than this value will be rejected to prevent memory exhaustion from malformed or malicious data. + Value must be greater than 24. + + + + + Enable or disable enforcement of the MaxConnections setting. + When true (default), new connections will be rejected when MaxConnections is reached. + When false, connections will be accepted beyond MaxConnections (legacy behavior) with a warning logged. + + Instantiate. diff --git a/src/WatsonTcp/WatsonTcpClient.cs b/src/WatsonTcp/WatsonTcpClient.cs index db81cb0..240a440 100644 --- a/src/WatsonTcp/WatsonTcpClient.cs +++ b/src/WatsonTcp/WatsonTcpClient.cs @@ -1,12 +1,16 @@ namespace WatsonTcp { using System; + using System.Buffers; + using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Net; using System.Net.Security; using System.Net.Sockets; +#if NET5_0_OR_GREATER using System.Runtime.InteropServices; +#endif using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; using System.Text; @@ -173,9 +177,7 @@ public ISerializationHelper SerializationHelper private DateTime _LastActivity = DateTime.UtcNow; private bool _IsTimeout = false; - private byte[] _SendBuffer = new byte[65536]; - private readonly object _SyncResponseLock = new object(); - private event EventHandler _SyncResponseReceived; + private readonly ConcurrentDictionary> _SyncRequests = new ConcurrentDictionary>(); #endregion @@ -196,7 +198,6 @@ public WatsonTcpClient( _Mode = Mode.Tcp; _ServerIp = serverIp; _ServerPort = serverPort; - _SendBuffer = new byte[_Settings.StreamBufferSize]; SerializationHelper.InstantiateConverter(); // Unity fix } @@ -223,7 +224,6 @@ public WatsonTcpClient( _TlsVersion = tlsVersion; _ServerIp = serverIp; _ServerPort = serverPort; - _SendBuffer = new byte[_Settings.StreamBufferSize]; if (!String.IsNullOrEmpty(pfxCertFile)) { @@ -284,7 +284,6 @@ public WatsonTcpClient( _SslCertificate = cert; _ServerIp = serverIp; _ServerPort = serverPort; - _SendBuffer = new byte[_Settings.StreamBufferSize]; _SslCertificateCollection = new X509Certificate2Collection { @@ -375,8 +374,7 @@ public void Connect() } finally { - // https://social.msdn.microsoft.com/Forums/en-US/313cf28c-2a6d-498e-8188-7a0639dbd552/tcpclientbeginconnect-issue?forum=netfxnetcom - // waitHandle.Close(); + waitHandle.Close(); } #endregion TCP @@ -445,8 +443,7 @@ public void Connect() } finally { - // https://social.msdn.microsoft.com/Forums/en-US/313cf28c-2a6d-498e-8188-7a0639dbd552/tcpclientbeginconnect-issue?forum=netfxnetcom - // waitHandle.Close(); + waitHandle.Close(); } #endregion SSL @@ -471,6 +468,7 @@ public void Connect() _TokenSource = new CancellationTokenSource(); _Token = _TokenSource.Token; + _MessageBuilder.MaxHeaderSize = _Settings.MaxHeaderSize; _LastActivity = DateTime.UtcNow; _IsTimeout = false; @@ -524,15 +522,21 @@ public void Disconnect(bool sendNotice = true) _Client.Close(); } - while (_DataReceiver?.IsCompleted == false) + try { - Task.Delay(10).Wait(); + if (_DataReceiver != null && !_DataReceiver.IsCompleted) + _DataReceiver.Wait(TimeSpan.FromSeconds(5)); } + catch (AggregateException) { } + catch (ObjectDisposedException) { } - while (_IdleServerMonitor?.IsCompleted == false) + try { - Task.Delay(10).Wait(); + if (_IdleServerMonitor != null && !_IdleServerMonitor.IsCompleted) + _IdleServerMonitor.Wait(TimeSpan.FromSeconds(5)); } + catch (AggregateException) { } + catch (ObjectDisposedException) { } Connected = false; @@ -566,7 +570,7 @@ public async Task AuthenticateAsync(string presharedKey, CancellationToken token /// Boolean indicating if the message was sent successfully. public async Task SendAsync(string data, Dictionary metadata = null, CancellationToken token = default) { - if (String.IsNullOrEmpty(data)) return await SendAsync(Array.Empty(), metadata); + if (String.IsNullOrEmpty(data)) return await SendAsync(Array.Empty(), metadata, 0, token); if (token == default(CancellationToken)) token = _Token; return await SendAsync(Encoding.UTF8.GetBytes(data), metadata, 0, token).ConfigureAwait(false); } @@ -936,14 +940,22 @@ private async Task DataReceiver(CancellationToken token) if (DateTime.UtcNow < msg.ExpirationUtc.Value) { - lock (_SyncResponseLock) + TaskCompletionSource tcs; + if (_SyncRequests.TryRemove(msg.ConversationGuid, out tcs)) { - _SyncResponseReceived?.Invoke(this,new SyncResponseReceivedEventArgs(msg,msgData)); + SyncResponse syncResp = new SyncResponse(msg.ConversationGuid, msg.ExpirationUtc.Value, msg.Metadata, msgData); + tcs.TrySetResult(syncResp); + } + else + { + _Settings.Logger?.Invoke(Severity.Warn, _Header + "synchronous response received for unknown conversation: " + msg.ConversationGuid.ToString()); } } else { _Settings.Logger?.Invoke(Severity.Debug, _Header + "expired synchronous response received and discarded"); + TaskCompletionSource tcs; + _SyncRequests.TryRemove(msg.ConversationGuid, out tcs); } } else @@ -954,7 +966,7 @@ private async Task DataReceiver(CancellationToken token) { msgData = await WatsonCommon.ReadMessageDataAsync(msg, _Settings.StreamBufferSize, token).ConfigureAwait(false); MessageReceivedEventArgs args = new MessageReceivedEventArgs(null, msg.Metadata, msgData); - await Task.Run(() => _Events.HandleMessageReceived(this, args)); + await Task.Run(() => _Events.HandleMessageReceived(this, args), token); } else if (_Events.IsUsingStreams) { @@ -1072,10 +1084,12 @@ private async Task SendInternalAsync(WatsonMessage msg, long contentLength } catch (TaskCanceledException) { + _Settings?.Logger?.Invoke(Severity.Debug, _Header + "send canceled"); return false; } catch (OperationCanceledException) { + _Settings?.Logger?.Invoke(Severity.Debug, _Header + "send operation canceled"); return false; } catch (Exception e) @@ -1117,23 +1131,11 @@ private async Task SendAndWaitInternalAsync(WatsonMessage msg, int throw new InvalidOperationException("Client is not connected to the server."); } - await _WriteLock.WaitAsync(token).ConfigureAwait(false); - - SyncResponse ret = null; - AutoResetEvent responded = new AutoResetEvent(false); - - // Create a new handler specially for this conversation - EventHandler handler = (sender, e) => - { - if (e.Message.ConversationGuid == msg.ConversationGuid) - { - ret = new SyncResponse(msg.ConversationGuid, e.Message.ExpirationUtc.Value, e.Message.Metadata, e.Data); - responded.Set(); - } - }; + // Register a TaskCompletionSource for this conversation before sending + TaskCompletionSource tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _SyncRequests[msg.ConversationGuid] = tcs; - // Subscribe - _SyncResponseReceived += handler; + await _WriteLock.WaitAsync(token).ConfigureAwait(false); try { @@ -1146,16 +1148,18 @@ private async Task SendAndWaitInternalAsync(WatsonMessage msg, int } catch (TaskCanceledException) { + _SyncRequests.TryRemove(msg.ConversationGuid, out _); return null; } catch (OperationCanceledException) { + _SyncRequests.TryRemove(msg.ConversationGuid, out _); return null; } catch (Exception e) { _Settings.Logger?.Invoke(Severity.Error, _Header + "failed to write message to " + _ServerIp + ":" + _ServerPort + ": " + e.Message); - _SyncResponseReceived -= handler; + _SyncRequests.TryRemove(msg.ConversationGuid, out _); disconnectDetected = true; throw; } @@ -1170,20 +1174,30 @@ private async Task SendAndWaitInternalAsync(WatsonMessage msg, int } } - // Wait for responded.Set() to be called - responded.WaitOne(new TimeSpan(0,0,0,0, timeoutMs)); + // Wait for the response with timeout + using (CancellationTokenSource timeoutCts = new CancellationTokenSource(timeoutMs)) + { + using (CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(token, timeoutCts.Token)) + { + try + { + linkedCts.Token.Register(() => tcs.TrySetCanceled()); + SyncResponse ret = await tcs.Task.ConfigureAwait(false); + return ret; + } + catch (TaskCanceledException) + { + _SyncRequests.TryRemove(msg.ConversationGuid, out _); - // Unsubscribe - _SyncResponseReceived -= handler; + if (timeoutCts.IsCancellationRequested) + { + _Settings.Logger?.Invoke(Severity.Error, _Header + "synchronous response not received within the timeout window"); + throw new TimeoutException("A response to a synchronous request was not received within the timeout window."); + } - if (ret != null) - { - return ret; - } - else - { - _Settings.Logger?.Invoke(Severity.Error, _Header + "synchronous response not received within the timeout window"); - throw new TimeoutException("A response to a synchronous request was not received within the timeout window."); + throw; + } + } } } @@ -1201,27 +1215,28 @@ private async Task SendDataStreamAsync(long contentLength, Stream stream, Cancel long bytesRemaining = contentLength; int bytesRead = 0; + int bufferSize = _Settings.StreamBufferSize; + byte[] buffer = ArrayPool.Shared.Rent(bufferSize); - while (bytesRemaining > 0) + try { - if (bytesRemaining >= _Settings.StreamBufferSize) + while (bytesRemaining > 0) { - _SendBuffer = new byte[_Settings.StreamBufferSize]; - } - else - { - _SendBuffer = new byte[bytesRemaining]; + int toRead = (int)Math.Min(bufferSize, bytesRemaining); + bytesRead = await stream.ReadAsync(buffer, 0, toRead, token).ConfigureAwait(false); + if (bytesRead > 0) + { + await _DataStream.WriteAsync(buffer, 0, bytesRead, token).ConfigureAwait(false); + bytesRemaining -= bytesRead; + } } - bytesRead = await stream.ReadAsync(_SendBuffer, 0, _SendBuffer.Length, token).ConfigureAwait(false); - if (bytesRead > 0) - { - await _DataStream.WriteAsync(_SendBuffer, 0, bytesRead, token).ConfigureAwait(false); - bytesRemaining -= bytesRead; - } + await _DataStream.FlushAsync(token).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(buffer); } - - await _DataStream.FlushAsync(token).ConfigureAwait(false); } #endregion @@ -1249,11 +1264,11 @@ private async Task IdleServerMonitor(CancellationToken token) } catch (TaskCanceledException) { - + _Settings?.Logger?.Invoke(Severity.Debug, _Header + "idle server monitor task canceled"); } catch (OperationCanceledException) { - + _Settings?.Logger?.Invoke(Severity.Debug, _Header + "idle server monitor operation canceled"); } catch (Exception e) { diff --git a/src/WatsonTcp/WatsonTcpClientEvents.cs b/src/WatsonTcp/WatsonTcpClientEvents.cs index 7c1e44e..1e3b8d5 100644 --- a/src/WatsonTcp/WatsonTcpClientEvents.cs +++ b/src/WatsonTcp/WatsonTcpClientEvents.cs @@ -133,7 +133,7 @@ internal void HandleExceptionEncountered(object sender, ExceptionEventArgs args) #region Private-Methods - internal void WrappedEventHandler(Action action, string handler, object sender) + internal static void WrappedEventHandler(Action action, string handler, object sender) { if (action == null) return; diff --git a/src/WatsonTcp/WatsonTcpClientSettings.cs b/src/WatsonTcp/WatsonTcpClientSettings.cs index 9707b54..b08c421 100644 --- a/src/WatsonTcp/WatsonTcpClientSettings.cs +++ b/src/WatsonTcp/WatsonTcpClientSettings.cs @@ -120,7 +120,7 @@ public int IdleServerEvaluationIntervalMs } set { - if (value < 1) throw new ArgumentOutOfRangeException("IdleServerEvaluationIntervalMs must be one or greater."); + if (value < 1) throw new ArgumentOutOfRangeException(nameof(IdleServerEvaluationIntervalMs), "IdleServerEvaluationIntervalMs must be one or greater."); _IdleServerEvaluationIntervalMs = value; } } @@ -160,6 +160,24 @@ public int LocalPort /// public bool NoDelay { get; set; } = true; + /// + /// Maximum header size in bytes. Default is 262144 (256KB). + /// Headers larger than this value will be rejected to prevent memory exhaustion from malformed or malicious data. + /// Value must be greater than 24. + /// + public int MaxHeaderSize + { + get + { + return _MaxHeaderSize; + } + set + { + if (value < 25) throw new ArgumentException("MaxHeaderSize must be greater than 24."); + _MaxHeaderSize = value; + } + } + #endregion #region Private-Members @@ -170,6 +188,7 @@ public int LocalPort private int _IdleServerTimeoutMs = 0; private int _IdleServerEvaluationIntervalMs = 1000; private int _LocalPort = 0; + private int _MaxHeaderSize = 262144; #endregion diff --git a/src/WatsonTcp/WatsonTcpClientSslConfiguration.cs b/src/WatsonTcp/WatsonTcpClientSslConfiguration.cs index 658c743..d53c4cf 100644 --- a/src/WatsonTcp/WatsonTcpClientSslConfiguration.cs +++ b/src/WatsonTcp/WatsonTcpClientSslConfiguration.cs @@ -83,7 +83,7 @@ public WatsonTcpClientSslConfiguration() public WatsonTcpClientSslConfiguration(WatsonTcpClientSslConfiguration configuration) { if (configuration == null) - throw new ArgumentNullException("Can not copy from null client SSL configuration"); + throw new ArgumentNullException(nameof(configuration), "Can not copy from null client SSL configuration"); _ClientCertSelectionCallback = configuration._ClientCertSelectionCallback; _ServerCertValidationCallback = configuration._ServerCertValidationCallback; diff --git a/src/WatsonTcp/WatsonTcpServer.cs b/src/WatsonTcp/WatsonTcpServer.cs index 5cf9799..14c3d7d 100644 --- a/src/WatsonTcp/WatsonTcpServer.cs +++ b/src/WatsonTcp/WatsonTcpServer.cs @@ -1,6 +1,7 @@ namespace WatsonTcp { using System; + using System.Buffers; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; @@ -9,8 +10,9 @@ using System.Net.NetworkInformation; using System.Net.Security; using System.Net.Sockets; +#if NET5_0_OR_GREATER using System.Runtime.InteropServices; - using System.Security.AccessControl; +#endif using System.Security.Cryptography.X509Certificates; using System.Text; using System.Threading; @@ -185,8 +187,7 @@ public bool IsListening private Task _AcceptConnections = null; private Task _MonitorClients = null; - private readonly object _SyncResponseLock = new object(); - private event EventHandler _SyncResponseReceived; + private readonly ConcurrentDictionary> _SyncRequests = new ConcurrentDictionary>(); #endregion @@ -209,12 +210,12 @@ public WatsonTcpServer( _Mode = Mode.Tcp; // According to the https://github.com/dotnet/WatsonTcp?tab=readme-ov-file#local-vs-external-connections - if (string.IsNullOrEmpty(listenerIp) || listenerIp.Equals("*") || listenerIp.Equals("+") || listenerIp.Equals("0.0.0.0")) + if (string.IsNullOrEmpty(listenerIp) || listenerIp.Equals("*", StringComparison.OrdinalIgnoreCase) || listenerIp.Equals("+", StringComparison.OrdinalIgnoreCase) || listenerIp.Equals("0.0.0.0", StringComparison.OrdinalIgnoreCase)) { _ListenerIpAddress = IPAddress.Any; _ListenerIp = _ListenerIpAddress.ToString(); } - else if (listenerIp.Equals("localhost") || listenerIp.Equals("127.0.0.1") || listenerIp.Equals("::1")) + else if (listenerIp.Equals("localhost", StringComparison.OrdinalIgnoreCase) || listenerIp.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) || listenerIp.Equals("::1", StringComparison.OrdinalIgnoreCase)) { _ListenerIpAddress = IPAddress.Loopback; _ListenerIp = _ListenerIpAddress.ToString(); @@ -259,7 +260,7 @@ public WatsonTcpServer( _ListenerIpAddress = IPAddress.Any; _ListenerIp = _ListenerIpAddress.ToString(); } - else if (listenerIp.Equals("localhost") || listenerIp.Equals("127.0.0.1") || listenerIp.Equals("::1")) + else if (listenerIp.Equals("localhost", StringComparison.OrdinalIgnoreCase) || listenerIp.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) || listenerIp.Equals("::1", StringComparison.OrdinalIgnoreCase)) { _ListenerIpAddress = IPAddress.Loopback; _ListenerIp = _ListenerIpAddress.ToString(); @@ -327,7 +328,7 @@ public WatsonTcpServer( _ListenerIpAddress = IPAddress.Any; _ListenerIp = _ListenerIpAddress.ToString(); } - else if (listenerIp.Equals("localhost") || listenerIp.Equals("127.0.0.1") || listenerIp.Equals("::1")) + else if (listenerIp.Equals("localhost", StringComparison.OrdinalIgnoreCase) || listenerIp.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) || listenerIp.Equals("::1", StringComparison.OrdinalIgnoreCase)) { _ListenerIpAddress = IPAddress.Loopback; _ListenerIp = _ListenerIpAddress.ToString(); @@ -386,6 +387,7 @@ public void Start() throw new ArgumentException("Unknown mode: " + _Mode.ToString()); } + _MessageBuilder.MaxHeaderSize = _Settings.MaxHeaderSize; _Listener.Start(); _AcceptConnections = Task.Run(() => AcceptConnections(_Token), _Token); // sets _IsListening _MonitorClients = Task.Run(() => MonitorForIdleClients(_Token), _Token); @@ -722,7 +724,7 @@ private async Task AcceptConnections(CancellationToken token) if (!_IsListening && (_Connections >= _Settings.MaxConnections)) { - await Task.Delay(100); + await Task.Delay(100, token); continue; } else if (!_IsListening) @@ -735,10 +737,22 @@ private async Task AcceptConnections(CancellationToken token) #region Accept-and-Validate +#if NET6_0_OR_GREATER + TcpClient tcpClient = await _Listener.AcceptTcpClientAsync(token).ConfigureAwait(false); +#else TcpClient tcpClient = await _Listener.AcceptTcpClientAsync().ConfigureAwait(false); +#endif tcpClient.LingerState.Enabled = false; tcpClient.NoDelay = _Settings.NoDelay; + // Enforce max connections - reject if at capacity + if (_Connections >= _Settings.MaxConnections && _Settings.EnforceMaxConnections) + { + _Settings.Logger?.Invoke(Severity.Info, _Header + "rejecting connection, maximum connections " + _Settings.MaxConnections + " reached (currently " + _Connections + " connections)"); + tcpClient.Close(); + continue; + } + if (_Keepalive.EnableTcpKeepAlives) EnableKeepalives(tcpClient); string clientIp = ((IPEndPoint)tcpClient.Client.RemoteEndPoint).Address.ToString(); @@ -757,7 +771,6 @@ private async Task AcceptConnections(CancellationToken token) } ClientMetadata client = new ClientMetadata(tcpClient); - client.SendBuffer = new byte[_Settings.StreamBufferSize]; _ClientManager.AddClient(client.Guid, client); _ClientManager.AddClientLastSeen(client.Guid); @@ -769,12 +782,16 @@ private async Task AcceptConnections(CancellationToken token) #region Check-for-Maximum-Connections Interlocked.Increment(ref _Connections); - if (_Connections >= _Settings.MaxConnections) + if (_Connections >= _Settings.MaxConnections && _Settings.EnforceMaxConnections) { _Settings.Logger?.Invoke(Severity.Info, _Header + "maximum connections " + _Settings.MaxConnections + " met (currently " + _Connections + " connections), pausing"); _IsListening = false; _Listener.Stop(); } + else if (_Connections >= _Settings.MaxConnections) + { + _Settings.Logger?.Invoke(Severity.Warn, _Header + "maximum connections " + _Settings.MaxConnections + " exceeded (currently " + _Connections + " connections), enforcement disabled"); + } #endregion @@ -910,7 +927,7 @@ private async Task FinalizeConnection(ClientMetadata client, CancellationToken t #endregion } - private bool IsClientConnected(ClientMetadata client) + private static bool IsClientConnected(ClientMetadata client) { if (client != null && client.TcpClient != null) { @@ -1008,7 +1025,9 @@ private async Task DataReceiver(ClientMetadata client, CancellationToken token) if (!IsClientConnected(client)) break; +#pragma warning disable CA2016 // token intentionally not forwarded - stream closure is the proper disconnect signal WatsonMessage msg = await _MessageBuilder.BuildFromStream(client.DataStream); +#pragma warning restore CA2016 if (msg == null) { await Task.Delay(30, token).ConfigureAwait(false); @@ -1032,7 +1051,7 @@ private async Task DataReceiver(ClientMetadata client, CancellationToken token) if (msg.PresharedKey != null && msg.PresharedKey.Length > 0) { string clientPsk = Encoding.UTF8.GetString(msg.PresharedKey).Trim(); - if (_Settings.PresharedKey.Trim().Equals(clientPsk)) + if (_Settings.PresharedKey.Trim().Equals(clientPsk, StringComparison.Ordinal)) { _Settings.Logger?.Invoke(Severity.Debug, _Header + "accepted authentication for " + client.ToString()); _ClientManager.RemoveUnauthenticatedClient(client.Guid); @@ -1154,14 +1173,22 @@ private async Task DataReceiver(ClientMetadata client, CancellationToken token) if (DateTime.UtcNow < msg.ExpirationUtc.Value) { - lock (_SyncResponseLock) + TaskCompletionSource tcs; + if (_SyncRequests.TryRemove(msg.ConversationGuid, out tcs)) + { + SyncResponse syncResp = new SyncResponse(msg.ConversationGuid, msg.ExpirationUtc.Value, msg.Metadata, msgData); + tcs.TrySetResult(syncResp); + } + else { - _SyncResponseReceived?.Invoke(this, new SyncResponseReceivedEventArgs(msg, msgData)); + _Settings.Logger?.Invoke(Severity.Warn, _Header + "synchronous response received for unknown conversation from " + client.ToString() + ": " + msg.ConversationGuid.ToString()); } } else { _Settings.Logger?.Invoke(Severity.Debug, _Header + "expired synchronous response received and discarded from " + client.ToString()); + TaskCompletionSource tcs; + _SyncRequests.TryRemove(msg.ConversationGuid, out tcs); } } else @@ -1288,10 +1315,12 @@ private async Task SendInternalAsync(ClientMetadata client, WatsonMessage } catch (TaskCanceledException) { + _Settings?.Logger?.Invoke(Severity.Debug, _Header + "send to " + client.ToString() + " canceled"); return false; } catch (OperationCanceledException) { + _Settings?.Logger?.Invoke(Severity.Debug, _Header + "send to " + client.ToString() + " operation canceled"); return false; } catch (Exception e) @@ -1319,23 +1348,11 @@ private async Task SendAndWaitInternalAsync(ClientMetadata client, } } - await client.WriteLock.WaitAsync(); - - SyncResponse ret = null; - AutoResetEvent responded = new AutoResetEvent(false); - - // Create a new handler specially for this Conversation. - EventHandler handler = (sender, e) => - { - if (e.Message.ConversationGuid == msg.ConversationGuid) - { - ret = new SyncResponse(e.Message.ConversationGuid, e.Message.ExpirationUtc.Value, e.Message.Metadata, e.Data); - responded.Set(); - } - }; + // Register a TaskCompletionSource for this conversation before sending + TaskCompletionSource tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _SyncRequests[msg.ConversationGuid] = tcs; - // Subscribe - _SyncResponseReceived += handler; + await client.WriteLock.WaitAsync(token); try { @@ -1350,7 +1367,7 @@ private async Task SendAndWaitInternalAsync(ClientMetadata client, { _Settings.Logger?.Invoke(Severity.Error, _Header + client.ToString() + " failed to write message: " + e.Message); _Events.HandleExceptionEncountered(this, new ExceptionEventArgs(e)); - _SyncResponseReceived -= handler; + _SyncRequests.TryRemove(msg.ConversationGuid, out _); throw; } finally @@ -1358,20 +1375,30 @@ private async Task SendAndWaitInternalAsync(ClientMetadata client, if (client != null) client.WriteLock.Release(); } - // Wait for responded.Set() to be called - responded.WaitOne(new TimeSpan(0, 0, 0, 0, timeoutMs)); + // Wait for the response with timeout + using (CancellationTokenSource timeoutCts = new CancellationTokenSource(timeoutMs)) + { + using (CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(token, timeoutCts.Token)) + { + try + { + linkedCts.Token.Register(() => tcs.TrySetCanceled()); + SyncResponse ret = await tcs.Task.ConfigureAwait(false); + return ret; + } + catch (TaskCanceledException) + { + _SyncRequests.TryRemove(msg.ConversationGuid, out _); - // Unsubscribe - _SyncResponseReceived -= handler; + if (timeoutCts.IsCancellationRequested) + { + _Settings.Logger?.Invoke(Severity.Error, _Header + "synchronous response not received within the timeout window"); + throw new TimeoutException("A response to a synchronous request was not received within the timeout window."); + } - if (ret != null) - { - return ret; - } - else - { - _Settings.Logger?.Invoke(Severity.Error, _Header + "synchronous response not received within the timeout window"); - throw new TimeoutException("A response to a synchronous request was not received within the timeout window."); + throw; + } + } } } @@ -1388,27 +1415,28 @@ private async Task SendDataStreamAsync(ClientMetadata client, long contentLength long bytesRemaining = contentLength; int bytesRead = 0; + int bufferSize = _Settings.StreamBufferSize; + byte[] buffer = ArrayPool.Shared.Rent(bufferSize); - while (bytesRemaining > 0) + try { - if (bytesRemaining >= _Settings.StreamBufferSize) - { - client.SendBuffer = new byte[_Settings.StreamBufferSize]; - } - else + while (bytesRemaining > 0) { - client.SendBuffer = new byte[bytesRemaining]; + int toRead = (int)Math.Min(bufferSize, bytesRemaining); + bytesRead = await stream.ReadAsync(buffer, 0, toRead, token).ConfigureAwait(false); + if (bytesRead > 0) + { + await client.DataStream.WriteAsync(buffer, 0, bytesRead, token).ConfigureAwait(false); + bytesRemaining -= bytesRead; + } } - bytesRead = await stream.ReadAsync(client.SendBuffer, 0, client.SendBuffer.Length, token).ConfigureAwait(false); - if (bytesRead > 0) - { - await client.DataStream.WriteAsync(client.SendBuffer, 0, bytesRead, token).ConfigureAwait(false); - bytesRemaining -= bytesRead; - } + await client.DataStream.FlushAsync(token).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(buffer); } - - await client.DataStream.FlushAsync(token).ConfigureAwait(false); } #endregion @@ -1417,6 +1445,8 @@ private async Task SendDataStreamAsync(ClientMetadata client, long contentLength private async Task MonitorForIdleClients(CancellationToken token) { + int purgeCounter = 0; + try { Dictionary lastSeen = null; @@ -1441,20 +1471,28 @@ private async Task MonitorForIdleClients(CancellationToken token) { _ClientManager.AddClientTimedout(curr.Key); _Settings.Logger?.Invoke(Severity.Debug, _Header + "disconnecting client " + curr.Key + " due to idle timeout"); - await DisconnectClientAsync(curr.Key, MessageStatus.Timeout, true); + await DisconnectClientAsync(curr.Key, MessageStatus.Timeout, true, token); } } } } + + // Purge stale kicked/timed-out records every ~60 seconds (12 iterations * 5s) + purgeCounter++; + if (purgeCounter >= 12) + { + purgeCounter = 0; + _ClientManager.PurgeStaleRecords(TimeSpan.FromMinutes(5)); + } } } catch (TaskCanceledException) { - + _Settings?.Logger?.Invoke(Severity.Debug, _Header + "idle client monitor task canceled"); } catch (OperationCanceledException) { - + _Settings?.Logger?.Invoke(Severity.Debug, _Header + "idle client monitor operation canceled"); } } diff --git a/src/WatsonTcp/WatsonTcpServerEvents.cs b/src/WatsonTcp/WatsonTcpServerEvents.cs index 5d2578b..260236b 100644 --- a/src/WatsonTcp/WatsonTcpServerEvents.cs +++ b/src/WatsonTcp/WatsonTcpServerEvents.cs @@ -163,7 +163,7 @@ internal void HandleExceptionEncountered(object sender, ExceptionEventArgs args) #region Private-Methods - internal void WrappedEventHandler(Action action, string handler, object sender) + internal static void WrappedEventHandler(Action action, string handler, object sender) { if (action == null) return; diff --git a/src/WatsonTcp/WatsonTcpServerSettings.cs b/src/WatsonTcp/WatsonTcpServerSettings.cs index 5fab10b..a83cafb 100644 --- a/src/WatsonTcp/WatsonTcpServerSettings.cs +++ b/src/WatsonTcp/WatsonTcpServerSettings.cs @@ -149,6 +149,31 @@ public List BlockedIPs /// public bool NoDelay { get; set; } = true; + /// + /// Maximum header size in bytes. Default is 262144 (256KB). + /// Headers larger than this value will be rejected to prevent memory exhaustion from malformed or malicious data. + /// Value must be greater than 24. + /// + public int MaxHeaderSize + { + get + { + return _MaxHeaderSize; + } + set + { + if (value < 25) throw new ArgumentException("MaxHeaderSize must be greater than 24."); + _MaxHeaderSize = value; + } + } + + /// + /// Enable or disable enforcement of the MaxConnections setting. + /// When true (default), new connections will be rejected when MaxConnections is reached. + /// When false, connections will be accepted beyond MaxConnections (legacy behavior) with a warning logged. + /// + public bool EnforceMaxConnections { get; set; } = true; + #endregion #region Private-Members @@ -160,6 +185,7 @@ public List BlockedIPs private int _IdleClientTimeoutSeconds = 0; private List _PermittedIPs = new List(); private List _BlockedIPs = new List(); + private int _MaxHeaderSize = 262144; #endregion diff --git a/src/WatsonTcp/WatsonTcpServerSslConfiguration.cs b/src/WatsonTcp/WatsonTcpServerSslConfiguration.cs index e049cc7..9301379 100644 --- a/src/WatsonTcp/WatsonTcpServerSslConfiguration.cs +++ b/src/WatsonTcp/WatsonTcpServerSslConfiguration.cs @@ -80,7 +80,7 @@ public WatsonTcpServerSslConfiguration() public WatsonTcpServerSslConfiguration(WatsonTcpServerSslConfiguration configuration) { if (configuration == null) - throw new ArgumentNullException("Can not copy from null server SSL configuration"); + throw new ArgumentNullException(nameof(configuration), "Can not copy from null server SSL configuration"); _ClientCertRequired = configuration._ClientCertRequired; _ClientCertValidationCallback = configuration._ClientCertValidationCallback;