From 6d3689e50cd6692337e52d2ffca1a4ec8ad793f1 Mon Sep 17 00:00:00 2001 From: Erik Hahne Tensaye Date: Sat, 28 Feb 2026 13:24:53 +0100 Subject: [PATCH 01/10] Add new build pipeline and update deps --- .github/workflows/build-test-publish.yml | 63 ++++++++++++++ AGENTS.md | 82 +++++++++++++++++++ mise.toml | 2 + src/Netler/Netler.csproj | 26 ++---- .../IntegrationTests/IntegrationTests.csproj | 12 +-- tests/UnitTests/UnitTests.csproj | 10 +-- version.json | 5 ++ 7 files changed, 168 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/build-test-publish.yml create mode 100644 AGENTS.md create mode 100644 mise.toml create mode 100644 version.json diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml new file mode 100644 index 0000000..3f7f7cc --- /dev/null +++ b/.github/workflows/build-test-publish.yml @@ -0,0 +1,63 @@ +name: Build, Test & Publish +permissions: + contents: write + packages: write + +on: + push: + branches: [master] + pull_request: + +jobs: + build: + name: Build, Test & Publish + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.x" + - name: Restore dependencies + run: dotnet restore Netler.sln + - name: Build + run: dotnet build Netler.sln --no-restore + - name: Test + run: dotnet test Netler.sln --no-build --verbosity normal + - name: Get version + if: github.ref_name == github.event.repository.default_branch + id: version + run: | + dotnet tool install --global nbgv + echo "version=$(nbgv get-version -v NuGetPackageVersion)" >> "$GITHUB_OUTPUT" + - name: Generate release notes + if: github.ref_name == github.event.repository.default_branch + id: release-notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + { + echo 'notes<> "$GITHUB_OUTPUT" + - name: Pack + if: github.ref_name == github.event.repository.default_branch + env: + RELEASE_NOTES: ${{ steps.release-notes.outputs.notes }} + run: | + printf '%s' "$RELEASE_NOTES" > release-notes.txt + dotnet pack src/Netler/Netler.csproj -c Release -o . + - name: Publish + if: github.ref_name == github.event.repository.default_branch + run: dotnet nuget push Netler.NET.*.nupkg -k ${{ secrets.NUGET_KEY }} -s https://api.nuget.org/v3/index.json + - name: Create GitHub Release + if: github.ref_name == github.event.repository.default_branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release create "v${{ steps.version.outputs.version }}" --generate-notes --title "v${{ steps.version.outputs.version }}" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0d17273 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,82 @@ +# AGENTS.md + +Guidelines for AI agents (Copilot, Claude, etc.) contributing to Netler.NET. + +--- + +## Project Structure & Module Organization + +``` +Netler.sln — Solution root +src/ + Netler/ + Server.cs — TCP server with fluent builder API + Client/ — Client-side connection logic + Messages/ — MessagePack-serialized message types + Request.cs — Request model + Response.cs — Response model + StreamExtensions.cs — Stream helpers (4-byte length-prefixed framing) +tests/ + UnitTests/ + RequestTests.cs — Protocol parsing tests (isolated) + ResponseTests.cs — Protocol parsing tests (isolated) + IntegrationTests/ + ServerTests.cs — Full server↔client TCP round-trip tests + sleep.sh / sleep.cmd — Helper scripts used by integration tests +DOCS.md — Auto-generated from XML doc comments +``` + +--- + +## Build, Test, and Development Commands + +```sh +# Restore dependencies +dotnet restore Netler.sln + +# Build entire solution +dotnet build Netler.sln + +# Run all tests +dotnet test Netler.sln + +# Run only unit tests +dotnet test tests/UnitTests/UnitTests.csproj + +# Run only integration tests +dotnet test tests/IntegrationTests/IntegrationTests.csproj + +# Pack NuGet package (Release configuration) +dotnet pack src/Netler/Netler.csproj -c Release +``` + +--- + +## Coding Style & Naming Conventions + +- Follow standard C# conventions: + - PascalCase for public members, types, and namespaces + - camelCase for local variables and parameters + - Braces on their own lines (Allman style) +- All TCP message payloads are serialized with **MessagePack** — do not introduce other serialization formats +- The server API uses a fluent builder style: `Server.Create(config => ...)` — preserve this pattern +- Messages are length-prefixed with a **4-byte header** describing the content length — maintain this framing +- Avoid compiler warnings; fix or suppress with justification if unavoidable + +--- + +## Testing Guidelines + +- Both test projects use the **xUnit** framework +- **UnitTests**: test protocol logic (request/response parsing) in isolation — no network I/O +- **IntegrationTests**: test full server↔client TCP round-trips; rely on `sleep.sh`/`sleep.cmd` helpers +- Run `dotnet test Netler.sln` locally before opening a PR +- Name test methods using the pattern: `Scenario_UnderTest_ExpectedOutcome` + +--- + +## Commit & PR Guidelines + +- Use present-tense imperative voice: `Add route parameter validation`, `Fix null reference in response parser` +- Use `[skip ci]` only for documentation-only commits (e.g. README/DOCS updates) +- PR descriptions should explain the change and reference which tests cover it diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..df386ef --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +dotnet = "10" diff --git a/src/Netler/Netler.csproj b/src/Netler/Netler.csproj index 4c0af56..f84bc50 100644 --- a/src/Netler/Netler.csproj +++ b/src/Netler/Netler.csproj @@ -1,36 +1,20 @@ - + netstandard2.0 Netler.NET - 1.$(TRAVIS_BUILD_NUMBER) - 1.0 Svan Jansson MIT https://github.com/svan-jansson/Netler.NET A library for cross-process method calls over TCP. For instance for calling .NET methods from Elixir (see https://hexdocs.pm/netler/). The library also contains a simple client that can be used to call a Netler server from another .NET application. - netler.svg.png - + true + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)\..\..\release-notes.txt")) - - - - - ..\..\DOCS.xml - - - - ..\..\DOCS.md - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + diff --git a/tests/IntegrationTests/IntegrationTests.csproj b/tests/IntegrationTests/IntegrationTests.csproj index 63408ec..1e4ea7a 100644 --- a/tests/IntegrationTests/IntegrationTests.csproj +++ b/tests/IntegrationTests/IntegrationTests.csproj @@ -1,16 +1,16 @@ - + - netcoreapp3.1 + net10.0 false - - - - + + + + diff --git a/tests/UnitTests/UnitTests.csproj b/tests/UnitTests/UnitTests.csproj index 118d5c8..70af659 100644 --- a/tests/UnitTests/UnitTests.csproj +++ b/tests/UnitTests/UnitTests.csproj @@ -1,16 +1,16 @@ - netcoreapp3.1 + net10.0 false - - - - + + + + diff --git a/version.json b/version.json new file mode 100644 index 0000000..9b90aef --- /dev/null +++ b/version.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "2.0", + "publicReleaseRefSpec": ["^refs/heads/master$"] +} From 4e7cbc4276ccdb2c993e7a4da90e5bd646b47ed5 Mon Sep 17 00:00:00 2001 From: Erik Hahne Tensaye Date: Sat, 28 Feb 2026 13:29:22 +0100 Subject: [PATCH 02/10] Update AGENTS.md --- AGENTS.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0d17273..c94ad35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,10 @@ Guidelines for AI agents (Copilot, Claude, etc.) contributing to Netler.NET. ``` Netler.sln — Solution root +mise.toml — Tool versions (dotnet 10) +version.json — Nerdbank.GitVersioning config (version 2.0, branch: master) +.github/workflows/ + build-test-publish.yml — CI: build, test, pack, publish to NuGet, create GitHub release src/ Netler/ Server.cs — TCP server with fluent builder API @@ -23,7 +27,7 @@ tests/ IntegrationTests/ ServerTests.cs — Full server↔client TCP round-trip tests sleep.sh / sleep.cmd — Helper scripts used by integration tests -DOCS.md — Auto-generated from XML doc comments +DOCS.md — Hand-maintained API documentation ``` --- @@ -78,5 +82,5 @@ dotnet pack src/Netler/Netler.csproj -c Release ## Commit & PR Guidelines - Use present-tense imperative voice: `Add route parameter validation`, `Fix null reference in response parser` -- Use `[skip ci]` only for documentation-only commits (e.g. README/DOCS updates) +- Use `[skip ci]` in the commit message only for documentation-only commits (e.g. README/DOCS updates) — GitHub Actions respects this convention - PR descriptions should explain the change and reference which tests cover it From 8a805e7d9d5bc71c2158ef35c318681ffd91e9d4 Mon Sep 17 00:00:00 2001 From: Erik Hahne Tensaye Date: Sat, 28 Feb 2026 13:38:56 +0100 Subject: [PATCH 03/10] Improvements and async support --- DOCS.md | 46 ++++++- src/Netler/Client.cs | 25 +++- src/Netler/Netler.csproj | 1 + src/Netler/Server.cs | 117 +++++++++++------- src/Netler/Server/Configuration.cs | 12 +- src/Netler/Server/Contracts/IConfiguration.cs | 13 +- src/Netler/StreamExtensions.cs | 52 +++++++- tests/IntegrationTests/ServerTests.cs | 45 +++---- 8 files changed, 234 insertions(+), 77 deletions(-) diff --git a/DOCS.md b/DOCS.md index e0c0ddb..872c534 100644 --- a/DOCS.md +++ b/DOCS.md @@ -8,6 +8,7 @@ - [#ctor(port,hostname)](#M-Netler-Client-#ctor-System-String,System-Int32- 'Netler.Client.#ctor(System.String,System.Int32)') - [Dispose()](#M-Netler-Client-Dispose 'Netler.Client.Dispose') - [Invoke(route,parameters)](#M-Netler-Client-Invoke-System-String,System-Object[]- 'Netler.Client.Invoke(System.String,System.Object[])') + - [InvokeAsync(route,parameters,cancellationToken)](#M-Netler-Client-InvokeAsync-System-String,System-Object[],System-Threading-CancellationToken- 'Netler.Client.InvokeAsync(System.String,System.Object[],System.Threading.CancellationToken)') - [ClientDisconnectBehaviour](#T-Netler-Contracts-ClientDisconnectBehaviour 'Netler.Contracts.ClientDisconnectBehaviour') - [DisposeServer](#F-Netler-Contracts-ClientDisconnectBehaviour-DisposeServer 'Netler.Contracts.ClientDisconnectBehaviour.DisposeServer') - [KeepAlive](#F-Netler-Contracts-ClientDisconnectBehaviour-KeepAlive 'Netler.Contracts.ClientDisconnectBehaviour.KeepAlive') @@ -23,6 +24,7 @@ - [GetRoutes()](#M-Netler-Contracts-IConfiguration-GetRoutes 'Netler.Contracts.IConfiguration.GetRoutes') - [UseClientDisconnectBehaviour()](#M-Netler-Contracts-IConfiguration-UseClientDisconnectBehaviour-Netler-Contracts-ClientDisconnectBehaviour- 'Netler.Contracts.IConfiguration.UseClientDisconnectBehaviour(Netler.Contracts.ClientDisconnectBehaviour)') - [UseClientPid()](#M-Netler-Contracts-IConfiguration-UseClientPid-System-Int32- 'Netler.Contracts.IConfiguration.UseClientPid(System.Int32)') + - [UseLogger(logger)](#M-Netler-Contracts-IConfiguration-UseLogger-Microsoft-Extensions-Logging-ILogger- 'Netler.Contracts.IConfiguration.UseLogger(Microsoft.Extensions.Logging.ILogger)') - [UsePort()](#M-Netler-Contracts-IConfiguration-UsePort-System-Int32- 'Netler.Contracts.IConfiguration.UsePort(System.Int32)') - [UseRoutes(routes)](#M-Netler-Contracts-IConfiguration-UseRoutes-System-Action{Netler-Contracts-IRoutes}- 'Netler.Contracts.IConfiguration.UseRoutes(System.Action{Netler.Contracts.IRoutes})') - [IRoutes](#T-Netler-Contracts-IRoutes 'Netler.Contracts.IRoutes') @@ -67,7 +69,7 @@ - [Invoke(route,parameters)](#M-Netler-Routes-Invoke-System-String,System-Object[]- 'Netler.Routes.Invoke(System.String,System.Object[])') - [Server](#T-Netler-Server 'Netler.Server') - [Create(configure)](#M-Netler-Server-Create-System-Action{Netler-Contracts-IConfiguration}- 'Netler.Server.Create(System.Action{Netler.Contracts.IConfiguration})') - - [Start()](#M-Netler-Server-Start 'Netler.Server.Start') + - [Start(cancellationToken)](#M-Netler-Server-Start-System-Threading-CancellationToken- 'Netler.Server.Start(System.Threading.CancellationToken)') - [Stop()](#M-Netler-Server-Stop 'Netler.Server.Stop') @@ -137,6 +139,25 @@ Invokes a method on the Netler server using its route | route | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | The name of the route | | parameters | [System.Object[]](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Object[] 'System.Object[]') | The parameters to pass to the method | + +### InvokeAsync(route,parameters,cancellationToken) `method` + +##### Summary + +Asynchronously invokes a method on the Netler server using its route + +##### Returns + +The return value of the remote method + +##### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| route | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | The name of the route | +| parameters | [System.Object[]](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Object[] 'System.Object[]') | The parameters to pass to the method | +| cancellationToken | [System.Threading.CancellationToken](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Threading.CancellationToken 'System.Threading.CancellationToken') | Token to cancel the operation | + ## ClientDisconnectBehaviour `type` @@ -283,6 +304,19 @@ By passing a client OS pid the Netler Server will automatically shut down when t This method has no parameters. + +### UseLogger(logger) `method` + +##### Summary + +Configures an `ILogger` for the server to write diagnostic messages to. Defaults to `NullLogger` when not set. + +##### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| logger | Microsoft.Extensions.Logging.ILogger | The logger instance | + ### UsePort() `method` @@ -824,16 +858,18 @@ Creates new Netler Server instance | ---- | ---- | ----------- | | configure | [System.Action{Netler.Contracts.IConfiguration}](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Action 'System.Action{Netler.Contracts.IConfiguration}') | Callback for configuring the server instance | - -### Start() `method` + +### Start(cancellationToken) `method` ##### Summary -Starts a process running the Netler Server +Starts the Netler Server ##### Parameters -This method has no parameters. +| Name | Type | Description | +| ---- | ---- | ----------- | +| cancellationToken | [System.Threading.CancellationToken](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Threading.CancellationToken 'System.Threading.CancellationToken') | Token to cancel the server (optional) | ### Stop() `method` diff --git a/src/Netler/Client.cs b/src/Netler/Client.cs index dc3936a..d9cd7e1 100644 --- a/src/Netler/Client.cs +++ b/src/Netler/Client.cs @@ -1,7 +1,8 @@ -using Netler.Exceptions; +using Netler.Exceptions; using System; using System.Net.Sockets; using System.Threading; +using System.Threading.Tasks; namespace Netler { @@ -66,6 +67,28 @@ public object Invoke(string route, object[] parameters) return response.Data; } + /// + /// Asynchronously invokes a method on the Netler server using its route + /// + /// The name of the route + /// The parameters to pass to the method + /// Token to cancel the operation + /// The return value of the remote method + public async Task InvokeAsync(string route, object[] parameters, CancellationToken cancellationToken = default) + { + var message = new Request(route, parameters); + await _stream.WriteWithHeaderAsync(message.Encode(), cancellationToken); + var encodedResponse = await _stream.ReadWithHeaderAsync(cancellationToken); + var response = Response.Decode(encodedResponse); + + if (response.Status == Response.Code.Error) + { + throw new RemoteInvokationFailed($"Method at route {route} threw an error: {response.Data}"); + } + + return response.Data; + } + /// /// /// diff --git a/src/Netler/Netler.csproj b/src/Netler/Netler.csproj index f84bc50..5886a43 100644 --- a/src/Netler/Netler.csproj +++ b/src/Netler/Netler.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Netler/Server.cs b/src/Netler/Server.cs index 589807c..57a3b9c 100644 --- a/src/Netler/Server.cs +++ b/src/Netler/Server.cs @@ -1,4 +1,5 @@ -using Netler.Contracts; +using Microsoft.Extensions.Logging; +using Netler.Contracts; using Netler.Exceptions; using System; using System.Diagnostics; @@ -16,7 +17,6 @@ public class Server { private readonly IConfiguration _configuration; private CancellationTokenSource _cancellationSource; - private CancellationToken _cancellationToken; private Server() { @@ -35,13 +35,13 @@ public static Server Create(Action configure) } /// - /// Starts a process running the Netler Server + /// Starts the Netler Server /// - public Task Start() + /// Token to cancel the server + public Task Start(CancellationToken cancellationToken = default) { - _cancellationSource = new CancellationTokenSource(); - _cancellationToken = _cancellationSource.Token; - return Task.Run(StartServer, _cancellationToken); + _cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + return Task.Run(() => StartServerAsync(_cancellationSource.Token), _cancellationSource.Token); } /// @@ -49,74 +49,104 @@ public Task Start() /// public Server Stop() { - _cancellationSource.Cancel(); + _cancellationSource?.Cancel(); return this; } - private Server StartServer() + private async Task StartServerAsync(CancellationToken ct) { var port = _configuration.GetPort(); var routes = _configuration.GetRoutes(); var clientPid = _configuration.GetClientPid(); + var logger = _configuration.GetLogger(); - var localhost = IPAddress.Parse("127.0.0.1"); - var listener = new TcpListener(localhost, port); + var listener = new TcpListener(IPAddress.Loopback, port); + listener.Start(); + ct.Register(() => listener.Stop()); + + logger.LogInformation("Netler server listening on port {Port}", port); if (clientPid != null) { StartCheckingIfClientIsAlive( (int)clientPid, - (ClientDisconnectBehaviour)_configuration.GetClientDisconnectBehaviour()); + (ClientDisconnectBehaviour)_configuration.GetClientDisconnectBehaviour(), + ct, + logger); } - listener.Start(); - var client = listener.AcceptTcpClient(); - var stream = client.GetStream(); - - while (!_cancellationSource.IsCancellationRequested) + TcpClient tcpClient; + try { - if (!stream.DataAvailable) - { - Tick(1); - } - else - { + tcpClient = await listener.AcceptTcpClientAsync(); + } + catch (SocketException) when (ct.IsCancellationRequested) + { + logger.LogInformation("Netler server stopped before accepting a connection"); + return this; + } + + logger.LogInformation("Client connected"); - var encodedRequest = stream.ReadWithHeader(); - var request = Request.Decode(encodedRequest); + using (tcpClient) + { + var stream = tcpClient.GetStream(); + while (!ct.IsCancellationRequested) + { try { - var methodResponse = routes.Invoke(request.Route, request.Parameters); - var response = new Response(Response.Code.Ok, methodResponse); - stream.WriteWithHeader(response.Encode()); + var encodedRequest = await stream.ReadWithHeaderAsync(ct); + var request = Request.Decode(encodedRequest); + + logger.LogDebug("Received request for route {Route}", request.Route); + + try + { + var methodResponse = routes.Invoke(request.Route, request.Parameters); + var response = new Response(Response.Code.Ok, methodResponse); + await stream.WriteWithHeaderAsync(response.Encode(), ct); + } + catch (RouteMethodCallFailed ex) + { + logger.LogError(ex, "Route {Route} threw an exception", request.Route); + var response = new Response(Response.Code.Error, ex.InnerException.Message); + await stream.WriteWithHeaderAsync(response.Encode(), ct); + } } - catch (RouteMethodCallFailed ex) + catch (OperationCanceledException) { - var response = new Response(Response.Code.Error, ex.InnerException.Message); - stream.WriteWithHeader(response.Encode()); + break; + } + catch (System.IO.EndOfStreamException) + { + // Client closed the connection + break; + } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + logger.LogError(ex, "Unexpected error in server loop"); + break; } } } - listener.Stop(); + logger.LogInformation("Netler server stopped"); return this; } - private void Tick(int ms) => Task.Delay(ms).GetAwaiter().GetResult(); - - private void StartCheckingIfClientIsAlive(int clientPid, ClientDisconnectBehaviour behaviour) - => Task.Run(() => + private void StartCheckingIfClientIsAlive(int clientPid, ClientDisconnectBehaviour behaviour, CancellationToken ct, ILogger logger) + => Task.Run(async () => { - while (!_cancellationSource.IsCancellationRequested && ClientIsAlive(clientPid)) + while (!ct.IsCancellationRequested && ClientIsAlive(clientPid)) { - Tick(500); + try { await Task.Delay(500, ct); } + catch (OperationCanceledException) { return; } } - if (_cancellationSource.IsCancellationRequested) - { - return; - } + if (ct.IsCancellationRequested) return; + + logger.LogInformation("Client process {Pid} has disconnected. Behaviour: {Behaviour}", clientPid, behaviour); switch (behaviour) { @@ -130,7 +160,7 @@ private void StartCheckingIfClientIsAlive(int clientPid, ClientDisconnectBehavio case ClientDisconnectBehaviour.KeepAlive: break; } - }); + }, ct); private bool ClientIsAlive(int clientPid) { @@ -144,6 +174,5 @@ private bool ClientIsAlive(int clientPid) return false; } } - } } diff --git a/src/Netler/Server/Configuration.cs b/src/Netler/Server/Configuration.cs index 0ea7fbe..f3c190c 100644 --- a/src/Netler/Server/Configuration.cs +++ b/src/Netler/Server/Configuration.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Netler.Contracts; using System; @@ -12,6 +14,7 @@ internal class Configuration : IConfiguration private int? _clientPid; private ClientDisconnectBehaviour? _clientDisconnectBehaviour; private IRoutes _routes; + private ILogger _logger = NullLogger.Instance; public void UseClientPid(int pid) { @@ -35,6 +38,11 @@ public void UseRoutes(Action routes) routes(_routes); } + public void UseLogger(ILogger logger) + { + _logger = logger ?? NullLogger.Instance; + } + public int GetPort() => _port; public int? GetClientPid() => _clientPid; @@ -42,5 +50,7 @@ public void UseRoutes(Action routes) public ClientDisconnectBehaviour? GetClientDisconnectBehaviour() => _clientDisconnectBehaviour; public IRoutes GetRoutes() => _routes; + + public ILogger GetLogger() => _logger; } -} \ No newline at end of file +} diff --git a/src/Netler/Server/Contracts/IConfiguration.cs b/src/Netler/Server/Contracts/IConfiguration.cs index fdb7853..5d3ec01 100644 --- a/src/Netler/Server/Contracts/IConfiguration.cs +++ b/src/Netler/Server/Contracts/IConfiguration.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using System; namespace Netler.Contracts @@ -29,6 +30,12 @@ public interface IConfiguration /// A mapping of string routes to methods that are executed when the route is called void UseRoutes(Action routes); + /// + /// Configures an for the server to write diagnostic messages to. + /// Defaults to when not set. + /// + void UseLogger(ILogger logger); + /// /// The currently configured client disconnect behaviour /// @@ -49,5 +56,9 @@ public interface IConfiguration /// int? GetClientPid(); + /// + /// The currently configured logger + /// + ILogger GetLogger(); } -} \ No newline at end of file +} diff --git a/src/Netler/StreamExtensions.cs b/src/Netler/StreamExtensions.cs index ec07bc3..8956107 100644 --- a/src/Netler/StreamExtensions.cs +++ b/src/Netler/StreamExtensions.cs @@ -1,5 +1,8 @@ -using System; +using System; +using System.IO; using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; namespace Netler { @@ -10,11 +13,11 @@ internal static class StreamExtensions internal static byte[] ReadWithHeader(this NetworkStream stream) { var header = new byte[HeaderSize]; - stream.Read(header, 0, HeaderSize); + ReadExactly(stream, header, HeaderSize); Array.Reverse(header); var contentLength = BitConverter.ToInt32(header, 0); var content = new byte[contentLength]; - stream.Read(content, 0, contentLength); + ReadExactly(stream, content, contentLength); return content; } @@ -27,5 +30,48 @@ internal static void WriteWithHeader(this NetworkStream stream, byte[] content) content.CopyTo(packet, HeaderSize); stream.Write(packet, 0, packet.Length); } + + internal static async Task ReadWithHeaderAsync(this NetworkStream stream, CancellationToken cancellationToken = default) + { + var header = new byte[HeaderSize]; + await ReadExactlyAsync(stream, header, HeaderSize, cancellationToken); + Array.Reverse(header); + var contentLength = BitConverter.ToInt32(header, 0); + var content = new byte[contentLength]; + await ReadExactlyAsync(stream, content, contentLength, cancellationToken); + return content; + } + + internal static async Task WriteWithHeaderAsync(this NetworkStream stream, byte[] content, CancellationToken cancellationToken = default) + { + var header = BitConverter.GetBytes(content.Length); + Array.Reverse(header); + var packet = new byte[HeaderSize + content.Length]; + header.CopyTo(packet, 0); + content.CopyTo(packet, HeaderSize); + await stream.WriteAsync(packet, 0, packet.Length, cancellationToken); + } + + private static void ReadExactly(NetworkStream stream, byte[] buffer, int count) + { + int offset = 0; + while (offset < count) + { + int read = stream.Read(buffer, offset, count - offset); + if (read == 0) throw new EndOfStreamException(); + offset += read; + } + } + + private static async Task ReadExactlyAsync(NetworkStream stream, byte[] buffer, int count, CancellationToken cancellationToken) + { + int offset = 0; + while (offset < count) + { + int read = await stream.ReadAsync(buffer, offset, count - offset, cancellationToken); + if (read == 0) throw new EndOfStreamException(); + offset += read; + } + } } } diff --git a/tests/IntegrationTests/ServerTests.cs b/tests/IntegrationTests/ServerTests.cs index ee67490..1a4d04b 100644 --- a/tests/IntegrationTests/ServerTests.cs +++ b/tests/IntegrationTests/ServerTests.cs @@ -15,7 +15,7 @@ namespace IntegrationTests public class ServerTests { [Fact] - public void ClientServerCommunication() + public async Task ClientServerCommunication() { var port = FreeTcpPort(); @@ -39,22 +39,22 @@ public void ClientServerCommunication() int? actual = null; var serverTask = server.Start(); - var clientTask = Task.Run(() => + var clientTask = Task.Run(async () => { using (var client = new Client(port)) { - actual = Convert.ToInt32(client.Invoke("Add", new object[] { 2, 3 })); + actual = Convert.ToInt32(await client.InvokeAsync("Add", new object[] { 2, 3 })); } server.Stop(); }); - Task.WaitAll(serverTask, clientTask); + await Task.WhenAll(serverTask, clientTask); Assert.Equal(expected, actual); } - [Fact] - public void LargeContent() + [Fact(Timeout = 10_000)] + public async Task LargeContent() { var port = FreeTcpPort(); @@ -86,22 +86,22 @@ public void LargeContent() object[] actual = null; var serverTask = server.Start(); - var clientTask = Task.Run(() => + var clientTask = Task.Run(async () => { using (var client = new Client(port)) { - actual = client.Invoke("Large", new object[] { expectedSize }) as object[]; + actual = await client.InvokeAsync("Large", new object[] { expectedSize }) as object[]; } server.Stop(); }); - Task.WaitAll(serverTask, clientTask); + await Task.WhenAll(serverTask, clientTask); Assert.Equal(expectedSize, actual.Length); } [Fact] - public void ClientIsReusable() + public async Task ClientIsReusable() { var port = FreeTcpPort(); @@ -127,24 +127,24 @@ public void ClientIsReusable() int? secondActual = null; var serverTask = server.Start(); - var clientTask = Task.Run(() => + var clientTask = Task.Run(async () => { using (var client = new Client(port)) { - firstActual = Convert.ToInt32(client.Invoke("Add", new object[] { 2, 3 })); - secondActual = Convert.ToInt32(client.Invoke("Add", new object[] { 30, 7 })); + firstActual = Convert.ToInt32(await client.InvokeAsync("Add", new object[] { 2, 3 })); + secondActual = Convert.ToInt32(await client.InvokeAsync("Add", new object[] { 30, 7 })); } server.Stop(); }); - Task.WaitAll(serverTask, clientTask); + await Task.WhenAll(serverTask, clientTask); Assert.Equal(firstExected, firstActual); Assert.Equal(secondExected, secondActual); } [Fact] - public void ClientCatchesServerExceptions() + public async Task ClientCatchesServerExceptions() { var port = FreeTcpPort(); @@ -163,21 +163,21 @@ public void ClientCatchesServerExceptions() var serverTask = server.Start(); - var clientTask = Task.Run(() => + var clientTask = Task.Run(async () => { using (var client = new Client(port)) { - Assert.Throws(() => { client.Invoke("Add", new object[] { 2, 3 }); }); + await Assert.ThrowsAsync(() => client.InvokeAsync("Add", new object[] { 2, 3 })); } server.Stop(); }); - Task.WaitAll(serverTask, clientTask); + await Task.WhenAll(serverTask, clientTask); } [Fact(Skip = "CI")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "xUnit1004:Test methods should not be skipped", Justification = "Cannot get Travis CI to run this test")] - public void ServerCanListenToClientProcessStatus() + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "xUnit1004:Test methods should not be skipped", Justification = "Cannot be reliably run in CI environment")] + public async Task ServerCanListenToClientProcessStatus() { var port = FreeTcpPort(); var clientPid = StartProcessThatRunsFiveSeconds(); @@ -191,12 +191,13 @@ public void ServerCanListenToClientProcessStatus() }); var serverTask = server.Start(); - var clientTask = Task.Run(() => + var clientTask = Task.Run(async () => { using var client = new Client(port); + await Task.CompletedTask; }); - Task.WaitAll(serverTask, clientTask); + await Task.WhenAll(serverTask, clientTask); Assert.True(true); } From c2a67049b44530066a10fb123c81ca35ab62244f Mon Sep 17 00:00:00 2001 From: Erik Hahne Tensaye Date: Sat, 28 Feb 2026 16:01:50 +0100 Subject: [PATCH 04/10] Typed helpers --- DOCS.md | 71 +++++++++++++ src/Netler/Client.cs | 14 +++ src/Netler/Params.cs | 66 ++++++++++++ src/Netler/Server/TypedRouteExtensions.cs | 65 ++++++++++++ src/Netler/TypedConvert.cs | 32 ++++++ tests/IntegrationTests/ServerTests.cs | 122 ++++++++++++++++++++++ tests/UnitTests/TypedConvertTests.cs | 81 ++++++++++++++ 7 files changed, 451 insertions(+) create mode 100644 src/Netler/Params.cs create mode 100644 src/Netler/Server/TypedRouteExtensions.cs create mode 100644 src/Netler/TypedConvert.cs create mode 100644 tests/UnitTests/TypedConvertTests.cs diff --git a/DOCS.md b/DOCS.md index 872c534..7023a85 100644 --- a/DOCS.md +++ b/DOCS.md @@ -9,6 +9,11 @@ - [Dispose()](#M-Netler-Client-Dispose 'Netler.Client.Dispose') - [Invoke(route,parameters)](#M-Netler-Client-Invoke-System-String,System-Object[]- 'Netler.Client.Invoke(System.String,System.Object[])') - [InvokeAsync(route,parameters,cancellationToken)](#M-Netler-Client-InvokeAsync-System-String,System-Object[],System-Threading-CancellationToken- 'Netler.Client.InvokeAsync(System.String,System.Object[],System.Threading.CancellationToken)') + - [InvokeAsync\`\`1(route,parameters,cancellationToken)](#M-Netler-Client-InvokeAsync``1-System-String,System-Object[],System-Threading-CancellationToken- 'Netler.Client.InvokeAsync``1(System.String,System.Object[],System.Threading.CancellationToken)') +- [Params](#T-Netler-Params 'Netler.Params') + - [Decode overloads (Func/Action, 0–4 params)](#M-Netler-Params-Decode 'Netler.Params.Decode') +- [TypedRouteExtensions](#T-Netler-Contracts-TypedRouteExtensions 'Netler.Contracts.TypedRouteExtensions') + - [AddTyped overloads (Func/Action, 0–4 params)](#M-Netler-Contracts-TypedRouteExtensions-AddTyped 'Netler.Contracts.TypedRouteExtensions.AddTyped') - [ClientDisconnectBehaviour](#T-Netler-Contracts-ClientDisconnectBehaviour 'Netler.Contracts.ClientDisconnectBehaviour') - [DisposeServer](#F-Netler-Contracts-ClientDisconnectBehaviour-DisposeServer 'Netler.Contracts.ClientDisconnectBehaviour.DisposeServer') - [KeepAlive](#F-Netler-Contracts-ClientDisconnectBehaviour-KeepAlive 'Netler.Contracts.ClientDisconnectBehaviour.KeepAlive') @@ -139,6 +144,31 @@ Invokes a method on the Netler server using its route | route | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | The name of the route | | parameters | [System.Object[]](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Object[] 'System.Object[]') | The parameters to pass to the method | + +### InvokeAsync\`\`1(route,parameters,cancellationToken) `method` + +##### Summary + +Asynchronously invokes a method on the Netler server and deserialises the result to the requested type + +##### Returns + +The typed return value of the remote method + +##### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| route | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | The name of the route | +| parameters | [System.Object[]](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Object[] 'System.Object[]') | The parameters to pass to the method | +| cancellationToken | [System.Threading.CancellationToken](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Threading.CancellationToken 'System.Threading.CancellationToken') | Token to cancel the operation | + +##### Generic Types + +| Name | Description | +| ---- | ----------- | +| T | The expected return type. Primitives are coerced via `Convert.ChangeType`; complex types must be annotated with `[MessagePackObject]`. | + ### InvokeAsync(route,parameters,cancellationToken) `method` @@ -341,6 +371,47 @@ Which routes the Netler Server should expose | ---- | ---- | ----------- | | routes | [System.Action{Netler.Contracts.IRoutes}](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Action 'System.Action{Netler.Contracts.IRoutes}') | A mapping of string routes to methods that are executed when the route is called | + +## Params `type` + +##### Namespace + +Netler + +##### Summary + +Functional composition helper that wraps a typed delegate into the `Func` signature expected by `IRoutes.Add`. Each `Decode` overload returns the adapter function directly, so it composes cleanly at the point of route registration. Parameter decoding uses a three-step strategy: direct cast → `Convert.ChangeType` (primitive coercion) → MessagePack round-trip (for complex types with `[MessagePackObject]`). + +##### Example + +```csharp +routes.Add("Add", Params.Decode((a, b) => a + b)); +routes.Add("Double", Params.Decode(x => x * 2)); +routes.Add("Ping", Params.Decode(() => "pong")); +routes.Add("Log", Params.Decode(msg => { /* void */ })); +routes.Add("Create", Params.Decode(req => new CreateResponse { Id = 1 })); +``` + + +### Decode overloads `method` + +##### Summary + +Returns a `Func` that decodes the raw MessagePack parameter array, calls the provided typed delegate, and returns the result boxed as `object` (or `null` for `Action` overloads). Overloads exist for 0–4 parameters and for both `Func<…, TResult>` (returns a value) and `Action<…>` (void) delegates. + +| Signature | Description | +|---|---| +| `Decode(Func)` | 0 params, returns value | +| `Decode(Func)` | 1 param, returns value | +| `Decode(Func)` | 2 params, returns value | +| `Decode(Func)` | 3 params, returns value | +| `Decode(Func)` | 4 params, returns value | +| `Decode(Action)` | 0 params, void | +| `Decode(Action)` | 1 param, void | +| `Decode(Action)` | 2 params, void | +| `Decode(Action)` | 3 params, void | +| `Decode(Action)` | 4 params, void | + ## IRoutes `type` diff --git a/src/Netler/Client.cs b/src/Netler/Client.cs index d9cd7e1..f00167a 100644 --- a/src/Netler/Client.cs +++ b/src/Netler/Client.cs @@ -89,6 +89,20 @@ public async Task InvokeAsync(string route, object[] parameters, Cancell return response.Data; } + /// + /// Asynchronously invokes a method on the Netler server using its route and deserialises the result to + /// + /// The expected return type + /// The name of the route + /// The parameters to pass to the method + /// Token to cancel the operation + /// The typed return value of the remote method + public async Task InvokeAsync(string route, object[] parameters, CancellationToken cancellationToken = default) + { + var raw = await InvokeAsync(route, parameters, cancellationToken); + return TypedConvert.To(raw); + } + /// /// /// diff --git a/src/Netler/Params.cs b/src/Netler/Params.cs new file mode 100644 index 0000000..60775f1 --- /dev/null +++ b/src/Netler/Params.cs @@ -0,0 +1,66 @@ +using System; + +namespace Netler +{ + /// + /// Functional composition helper that wraps a typed delegate into the + /// Func<object[], object> signature expected by IRoutes.Add. + /// Parameter decoding (direct cast → primitive coercion → MessagePack round-trip) + /// is handled transparently; complex types must carry [MessagePackObject]. + /// + /// + /// + /// routes.Add("Add", Params.Decode<int, int, int>((a, b) => a + b)); + /// routes.Add("Double", Params.Decode<int, int>(x => x * 2)); + /// routes.Add("Ping", Params.Decode<string>(() => "pong")); + /// routes.Add("Log", Params.Decode<string>(msg => { /* void */ })); + /// routes.Add("Create", Params.Decode<CreateRequest, CreateResponse>(req => new CreateResponse { Id = 1 })); + /// + /// + public static class Params + { + // ── Func overloads (with return value) ─────────────────────────────── + + /// Wraps a zero-parameter function. + public static Func Decode(Func fn) + => _ => fn(); + + /// Wraps a one-parameter function. + public static Func Decode(Func fn) + => p => fn(TypedConvert.To(p[0])); + + /// Wraps a two-parameter function. + public static Func Decode(Func fn) + => p => fn(TypedConvert.To(p[0]), TypedConvert.To(p[1])); + + /// Wraps a three-parameter function. + public static Func Decode(Func fn) + => p => fn(TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2])); + + /// Wraps a four-parameter function. + public static Func Decode(Func fn) + => p => fn(TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2]), TypedConvert.To(p[3])); + + // ── Action overloads (void, route returns null) ─────────────────────── + + /// Wraps a zero-parameter void action. + public static Func Decode(Action fn) + => _ => { fn(); return null; }; + + /// Wraps a one-parameter void action. + public static Func Decode(Action fn) + => p => { fn(TypedConvert.To(p[0])); return null; }; + + /// Wraps a two-parameter void action. + public static Func Decode(Action fn) + => p => { fn(TypedConvert.To(p[0]), TypedConvert.To(p[1])); return null; }; + + /// Wraps a three-parameter void action. + public static Func Decode(Action fn) + => p => { fn(TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2])); return null; }; + + /// Wraps a four-parameter void action. + public static Func Decode(Action fn) + => p => { fn(TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2]), TypedConvert.To(p[3])); return null; }; + } +} diff --git a/src/Netler/Server/TypedRouteExtensions.cs b/src/Netler/Server/TypedRouteExtensions.cs new file mode 100644 index 0000000..d76e08d --- /dev/null +++ b/src/Netler/Server/TypedRouteExtensions.cs @@ -0,0 +1,65 @@ +using System; + +namespace Netler.Contracts +{ + /// + /// Typed overloads for that compose automatically with + /// . Type parameters are inferred from the handler + /// lambda; the underlying wire format is unchanged. + /// + /// + /// + /// routes.AddTyped("Add", (int a, int b) => a + b); + /// routes.AddTyped("Double", (int x) => x * 2); + /// routes.AddTyped("Ping", () => "pong"); + /// routes.AddTyped("Log", (string msg) => { /* void */ }); + /// routes.AddTyped("Create", (CreateRequest req) => new CreateResponse { Id = 1 }); + /// + /// + public static class TypedRouteExtensions + { + // ── Func overloads (with return value) ─────────────────────────────── + + /// Adds a typed route with a zero-parameter handler. + public static void AddTyped(this IRoutes routes, string route, Func fn) + => routes.Add(route, Params.Decode(fn)); + + /// Adds a typed route with a one-parameter handler. + public static void AddTyped(this IRoutes routes, string route, Func fn) + => routes.Add(route, Params.Decode(fn)); + + /// Adds a typed route with a two-parameter handler. + public static void AddTyped(this IRoutes routes, string route, Func fn) + => routes.Add(route, Params.Decode(fn)); + + /// Adds a typed route with a three-parameter handler. + public static void AddTyped(this IRoutes routes, string route, Func fn) + => routes.Add(route, Params.Decode(fn)); + + /// Adds a typed route with a four-parameter handler. + public static void AddTyped(this IRoutes routes, string route, Func fn) + => routes.Add(route, Params.Decode(fn)); + + // ── Action overloads (void return) ─────────────────────────────────── + + /// Adds a typed route with a zero-parameter void handler. + public static void AddTyped(this IRoutes routes, string route, Action fn) + => routes.Add(route, Params.Decode(fn)); + + /// Adds a typed route with a one-parameter void handler. + public static void AddTyped(this IRoutes routes, string route, Action fn) + => routes.Add(route, Params.Decode(fn)); + + /// Adds a typed route with a two-parameter void handler. + public static void AddTyped(this IRoutes routes, string route, Action fn) + => routes.Add(route, Params.Decode(fn)); + + /// Adds a typed route with a three-parameter void handler. + public static void AddTyped(this IRoutes routes, string route, Action fn) + => routes.Add(route, Params.Decode(fn)); + + /// Adds a typed route with a four-parameter void handler. + public static void AddTyped(this IRoutes routes, string route, Action fn) + => routes.Add(route, Params.Decode(fn)); + } +} diff --git a/src/Netler/TypedConvert.cs b/src/Netler/TypedConvert.cs new file mode 100644 index 0000000..06a8913 --- /dev/null +++ b/src/Netler/TypedConvert.cs @@ -0,0 +1,32 @@ +using MessagePack; +using System; + +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("UnitTests")] + +namespace Netler +{ + internal static class TypedConvert + { + internal static T To(object raw) + { + if (raw == null) return default; + + // 1. Direct — already the right type (most common for primitives) + if (raw is T typed) return typed; + + // 2. Primitive coercion (int/long/double mismatches from MessagePack typeless decode) + if (raw is IConvertible) + { + try { return (T)Convert.ChangeType(raw, typeof(T)); } + catch (InvalidCastException) { } + catch (FormatException) { } + } + + // 3. [MessagePackObject] types: re-encode the raw value and decode as T + // Raw arrived via typeless deserialiser (e.g. as object[] for array-keyed objects). + // MessagePack's standard resolver handles [MessagePackObject] correctly. + var bytes = MessagePackSerializer.Serialize(raw); + return MessagePackSerializer.Deserialize(bytes); + } + } +} diff --git a/tests/IntegrationTests/ServerTests.cs b/tests/IntegrationTests/ServerTests.cs index 1a4d04b..02c4026 100644 --- a/tests/IntegrationTests/ServerTests.cs +++ b/tests/IntegrationTests/ServerTests.cs @@ -1,4 +1,6 @@ +using MessagePack; using Netler; +using Netler.Contracts; using Netler.Exceptions; using System; using System.Collections.Generic; @@ -202,6 +204,126 @@ public async Task ServerCanListenToClientProcessStatus() Assert.True(true); } + [Fact] + public async Task AddTyped_Primitives() + { + var port = FreeTcpPort(); + + var server = Server + .Create((config) => + { + config.UsePort(port); + config.UseRoutes((routes) => + { + routes.AddTyped("Add", (int a, int b) => a + b); + routes.AddTyped("Double", (int x) => x * 2); + routes.AddTyped("Ping", () => "pong"); + }); + }); + + int? sum = null; + int? doubled = null; + string ping = null; + + var serverTask = server.Start(); + var clientTask = Task.Run(async () => + { + using (var client = new Client(port)) + { + sum = await client.InvokeAsync("Add", new object[] { 3, 4 }); + doubled = await client.InvokeAsync("Double", new object[] { 6 }); + ping = await client.InvokeAsync("Ping", new object[] { }); + } + server.Stop(); + }); + + await Task.WhenAll(serverTask, clientTask); + + Assert.Equal(7, sum); + Assert.Equal(12, doubled); + Assert.Equal("pong", ping); + } + + [Fact] + public async Task AddTyped_VoidAction() + { + var port = FreeTcpPort(); + var logged = string.Empty; + + var server = Server + .Create((config) => + { + config.UsePort(port); + config.UseRoutes((routes) => + { + routes.AddTyped("Log", (string msg) => { logged = msg; }); + }); + }); + + var serverTask = server.Start(); + var clientTask = Task.Run(async () => + { + using (var client = new Client(port)) + { + await client.InvokeAsync("Log", new object[] { "hello" }); + } + server.Stop(); + }); + + await Task.WhenAll(serverTask, clientTask); + + Assert.Equal("hello", logged); + } + + [Fact] + public async Task AddTyped_MessagePackObject() + { + var port = FreeTcpPort(); + + var server = Server + .Create((config) => + { + config.UsePort(port); + config.UseRoutes((routes) => + { + routes.AddTyped("Echo", (EchoRequest req) => + new EchoResponse { Message = req.Text, Length = req.Text.Length }); + }); + }); + + EchoResponse actual = null; + + var serverTask = server.Start(); + var clientTask = Task.Run(async () => + { + using (var client = new Client(port)) + { + var request = new EchoRequest { Text = "netler" }; + actual = await client.InvokeAsync("Echo", new object[] { request }); + } + server.Stop(); + }); + + await Task.WhenAll(serverTask, clientTask); + + Assert.NotNull(actual); + Assert.Equal("netler", actual.Message); + Assert.Equal(6, actual.Length); + } + + [MessagePackObject] + public class EchoRequest + { + [Key(0)] public string Text { get; set; } + } + + [MessagePackObject] + public class EchoResponse + { + [Key(0)] public string Message { get; set; } + [Key(1)] public int Length { get; set; } + } + private int StartProcessThatRunsFiveSeconds() { string scriptFile; diff --git a/tests/UnitTests/TypedConvertTests.cs b/tests/UnitTests/TypedConvertTests.cs new file mode 100644 index 0000000..6950f5e --- /dev/null +++ b/tests/UnitTests/TypedConvertTests.cs @@ -0,0 +1,81 @@ +using MessagePack; +using Netler; +using Xunit; + +namespace UnitTests +{ + public class TypedConvertTests + { + [Fact] + public void DirectCast_ReturnsSameValue() + { + var result = TypedConvert.To(42); + Assert.Equal(42, result); + } + + [Fact] + public void DirectCast_String_ReturnsSameValue() + { + var result = TypedConvert.To("hello"); + Assert.Equal("hello", result); + } + + [Fact] + public void Null_ReturnsDefault() + { + var intResult = TypedConvert.To(null); + var stringResult = TypedConvert.To(null); + + Assert.Equal(0, intResult); + Assert.Null(stringResult); + } + + [Fact] + public void LongToInt_Coercion() + { + // MessagePack typeless decode can return long for small integers + long raw = 7L; + var result = TypedConvert.To(raw); + Assert.Equal(7, result); + } + + [Fact] + public void IntToLong_Coercion() + { + int raw = 99; + var result = TypedConvert.To(raw); + Assert.Equal(99L, result); + } + + [Fact] + public void DoubleToFloat_Coercion() + { + double raw = 3.14; + var result = TypedConvert.To(raw); + Assert.Equal((float)3.14, result, precision: 5); + } + + [Fact] + public void MessagePackObject_RoundTrip() + { + // Simulate what arrives after typeless MessagePack deserialization: + // a [MessagePackObject] type encoded then decoded as object[] (array-keyed). + var original = new TypedConvertPoint { X = 10, Y = 20 }; + var bytes = MessagePackSerializer.Serialize(original); + // Typeless decode gives object[] for array-keyed MessagePackObject + var raw = MessagePackSerializer.Deserialize(bytes); + + var result = TypedConvert.To(raw); + + Assert.Equal(10, result.X); + Assert.Equal(20, result.Y); + } + + [MessagePackObject] + public class TypedConvertPoint + { + [Key(0)] public int X { get; set; } + [Key(1)] public int Y { get; set; } + } + } +} From aa0e5d5093e2605bf0c758f4f688c8dff0e569d1 Mon Sep 17 00:00:00 2001 From: Erik Hahne Tensaye Date: Sat, 28 Feb 2026 16:04:02 +0100 Subject: [PATCH 05/10] Att T10 arities --- src/Netler/Params.cs | 77 +++++++++++++++++++++-- src/Netler/Server/TypedRouteExtensions.cs | 40 ++++++++++++ 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/src/Netler/Params.cs b/src/Netler/Params.cs index 60775f1..5450dc6 100644 --- a/src/Netler/Params.cs +++ b/src/Netler/Params.cs @@ -27,19 +27,57 @@ public static Func Decode(Func fn) /// Wraps a one-parameter function. public static Func Decode(Func fn) - => p => fn(TypedConvert.To(p[0])); + => p => fn( + TypedConvert.To(p[0])); /// Wraps a two-parameter function. public static Func Decode(Func fn) - => p => fn(TypedConvert.To(p[0]), TypedConvert.To(p[1])); + => p => fn( + TypedConvert.To(p[0]), TypedConvert.To(p[1])); /// Wraps a three-parameter function. public static Func Decode(Func fn) - => p => fn(TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2])); + => p => fn( + TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2])); /// Wraps a four-parameter function. public static Func Decode(Func fn) - => p => fn(TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2]), TypedConvert.To(p[3])); + => p => fn( + TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2]), + TypedConvert.To(p[3])); + + /// Wraps a five-parameter function. + public static Func Decode(Func fn) + => p => fn( + TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2]), + TypedConvert.To(p[3]), TypedConvert.To(p[4])); + + /// Wraps a six-parameter function. + public static Func Decode(Func fn) + => p => fn( + TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2]), + TypedConvert.To(p[3]), TypedConvert.To(p[4]), TypedConvert.To(p[5])); + + /// Wraps a seven-parameter function. + public static Func Decode(Func fn) + => p => fn( + TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2]), + TypedConvert.To(p[3]), TypedConvert.To(p[4]), TypedConvert.To(p[5]), + TypedConvert.To(p[6])); + + /// Wraps an eight-parameter function. + public static Func Decode(Func fn) + => p => fn( + TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2]), + TypedConvert.To(p[3]), TypedConvert.To(p[4]), TypedConvert.To(p[5]), + TypedConvert.To(p[6]), TypedConvert.To(p[7])); + + /// Wraps a nine-parameter function. + public static Func Decode(Func fn) + => p => fn( + TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2]), + TypedConvert.To(p[3]), TypedConvert.To(p[4]), TypedConvert.To(p[5]), + TypedConvert.To(p[6]), TypedConvert.To(p[7]), TypedConvert.To(p[8])); // ── Action overloads (void, route returns null) ─────────────────────── @@ -61,6 +99,35 @@ public static Func Decode(Action fn) /// Wraps a four-parameter void action. public static Func Decode(Action fn) - => p => { fn(TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2]), TypedConvert.To(p[3])); return null; }; + => p => { fn(TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2]), + TypedConvert.To(p[3])); return null; }; + + /// Wraps a five-parameter void action. + public static Func Decode(Action fn) + => p => { fn(TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2]), + TypedConvert.To(p[3]), TypedConvert.To(p[4])); return null; }; + + /// Wraps a six-parameter void action. + public static Func Decode(Action fn) + => p => { fn(TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2]), + TypedConvert.To(p[3]), TypedConvert.To(p[4]), TypedConvert.To(p[5])); return null; }; + + /// Wraps a seven-parameter void action. + public static Func Decode(Action fn) + => p => { fn(TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2]), + TypedConvert.To(p[3]), TypedConvert.To(p[4]), TypedConvert.To(p[5]), + TypedConvert.To(p[6])); return null; }; + + /// Wraps an eight-parameter void action. + public static Func Decode(Action fn) + => p => { fn(TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2]), + TypedConvert.To(p[3]), TypedConvert.To(p[4]), TypedConvert.To(p[5]), + TypedConvert.To(p[6]), TypedConvert.To(p[7])); return null; }; + + /// Wraps a nine-parameter void action. + public static Func Decode(Action fn) + => p => { fn(TypedConvert.To(p[0]), TypedConvert.To(p[1]), TypedConvert.To(p[2]), + TypedConvert.To(p[3]), TypedConvert.To(p[4]), TypedConvert.To(p[5]), + TypedConvert.To(p[6]), TypedConvert.To(p[7]), TypedConvert.To(p[8])); return null; }; } } diff --git a/src/Netler/Server/TypedRouteExtensions.cs b/src/Netler/Server/TypedRouteExtensions.cs index d76e08d..88089b8 100644 --- a/src/Netler/Server/TypedRouteExtensions.cs +++ b/src/Netler/Server/TypedRouteExtensions.cs @@ -40,6 +40,26 @@ public static void AddTyped(this IRoutes routes, string rou public static void AddTyped(this IRoutes routes, string route, Func fn) => routes.Add(route, Params.Decode(fn)); + /// Adds a typed route with a five-parameter handler. + public static void AddTyped(this IRoutes routes, string route, Func fn) + => routes.Add(route, Params.Decode(fn)); + + /// Adds a typed route with a six-parameter handler. + public static void AddTyped(this IRoutes routes, string route, Func fn) + => routes.Add(route, Params.Decode(fn)); + + /// Adds a typed route with a seven-parameter handler. + public static void AddTyped(this IRoutes routes, string route, Func fn) + => routes.Add(route, Params.Decode(fn)); + + /// Adds a typed route with an eight-parameter handler. + public static void AddTyped(this IRoutes routes, string route, Func fn) + => routes.Add(route, Params.Decode(fn)); + + /// Adds a typed route with a nine-parameter handler. + public static void AddTyped(this IRoutes routes, string route, Func fn) + => routes.Add(route, Params.Decode(fn)); + // ── Action overloads (void return) ─────────────────────────────────── /// Adds a typed route with a zero-parameter void handler. @@ -61,5 +81,25 @@ public static void AddTyped(this IRoutes routes, string route, Actio /// Adds a typed route with a four-parameter void handler. public static void AddTyped(this IRoutes routes, string route, Action fn) => routes.Add(route, Params.Decode(fn)); + + /// Adds a typed route with a five-parameter void handler. + public static void AddTyped(this IRoutes routes, string route, Action fn) + => routes.Add(route, Params.Decode(fn)); + + /// Adds a typed route with a six-parameter void handler. + public static void AddTyped(this IRoutes routes, string route, Action fn) + => routes.Add(route, Params.Decode(fn)); + + /// Adds a typed route with a seven-parameter void handler. + public static void AddTyped(this IRoutes routes, string route, Action fn) + => routes.Add(route, Params.Decode(fn)); + + /// Adds a typed route with an eight-parameter void handler. + public static void AddTyped(this IRoutes routes, string route, Action fn) + => routes.Add(route, Params.Decode(fn)); + + /// Adds a typed route with a nine-parameter void handler. + public static void AddTyped(this IRoutes routes, string route, Action fn) + => routes.Add(route, Params.Decode(fn)); } } From 0fad8fcfba2c64819cd91ad8b4879bbd538bb87c Mon Sep 17 00:00:00 2001 From: Erik Hahne Tensaye Date: Sat, 28 Feb 2026 16:16:42 +0100 Subject: [PATCH 06/10] Type conversion optimizations --- src/Netler/TypedConvert.cs | 123 ++++++++++++++++++++++++++++++++----- 1 file changed, 109 insertions(+), 14 deletions(-) diff --git a/src/Netler/TypedConvert.cs b/src/Netler/TypedConvert.cs index 06a8913..aafe3a5 100644 --- a/src/Netler/TypedConvert.cs +++ b/src/Netler/TypedConvert.cs @@ -1,5 +1,6 @@ using MessagePack; using System; +using System.Buffers; [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("UnitTests")] @@ -7,26 +8,120 @@ namespace Netler { internal static class TypedConvert { - internal static T To(object raw) + // Entry point: delegate immediately to the per-T cached converter. + internal static T To(object raw) => Converter.Invoke(raw); + + // ── Optimization 1 & 2: static generic converter cache ─────────────── + // Build() runs exactly once per closed type T (at first use). The + // resulting delegate is stored and reused on every subsequent call, + // eliminating the per-call if-chain, IConvertible vtable check, and + // Convert.ChangeType overhead. + // + // Each inline numeric branch replaces the general IConvertible path + // with a direct Convert.ToXxx call — no interface dispatch, no try/catch, + // no intermediate boxed object from ChangeType. + private static class Converter { - if (raw == null) return default; + internal static readonly Func Invoke = Build(); + + private static Func Build() + { + var t = typeof(T); + + if (t == typeof(int)) return raw => raw is T v ? v : raw == null ? default : (T)(object)Convert.ToInt32(raw); + if (t == typeof(long)) return raw => raw is T v ? v : raw == null ? default : (T)(object)Convert.ToInt64(raw); + if (t == typeof(double)) return raw => raw is T v ? v : raw == null ? default : (T)(object)Convert.ToDouble(raw); + if (t == typeof(float)) return raw => raw is T v ? v : raw == null ? default : (T)(object)Convert.ToSingle(raw); + if (t == typeof(decimal)) return raw => raw is T v ? v : raw == null ? default : (T)(object)Convert.ToDecimal(raw); + if (t == typeof(bool)) return raw => raw is T v ? v : raw == null ? default : (T)(object)Convert.ToBoolean(raw); + if (t == typeof(byte)) return raw => raw is T v ? v : raw == null ? default : (T)(object)Convert.ToByte(raw); + if (t == typeof(sbyte)) return raw => raw is T v ? v : raw == null ? default : (T)(object)Convert.ToSByte(raw); + if (t == typeof(short)) return raw => raw is T v ? v : raw == null ? default : (T)(object)Convert.ToInt16(raw); + if (t == typeof(ushort)) return raw => raw is T v ? v : raw == null ? default : (T)(object)Convert.ToUInt16(raw); + if (t == typeof(uint)) return raw => raw is T v ? v : raw == null ? default : (T)(object)Convert.ToUInt32(raw); + if (t == typeof(ulong)) return raw => raw is T v ? v : raw == null ? default : (T)(object)Convert.ToUInt64(raw); + if (t == typeof(char)) return raw => raw is T v ? v : raw == null ? default : (T)(object)Convert.ToChar(raw); + if (t == typeof(string)) return raw => raw is T v ? v : raw == null ? default : (T)(object)Convert.ToString(raw); - // 1. Direct — already the right type (most common for primitives) - if (raw is T typed) return typed; + // Complex / [MessagePackObject] types: direct cast first, then + // a pooled-buffer MessagePack round-trip (optimization 3). + return raw => + { + if (raw == null) return default; + if (raw is T typed) return typed; + return RoundTrip(raw); + }; + } + } + + // ── Optimization 3: pooled-buffer MessagePack round-trip ───────────── + // Serialises raw into a buffer rented from ArrayPool (no LOH + // pressure, no GC allocation for the wire bytes), then deserialises + // directly to T. The rented buffer is returned in the finally block. + private static T RoundTrip(object raw) + { + var writer = new PooledBufferWriter(); + try + { + MessagePackSerializer.Serialize(writer, raw); + return MessagePackSerializer.Deserialize(writer.WrittenMemory); + } + finally + { + writer.Dispose(); + } + } + + // Minimal IBufferWriter backed by ArrayPool. + // Grows exponentially by doubling when the rented segment is exhausted. + private sealed class PooledBufferWriter : IBufferWriter, IDisposable + { + private const int InitialSize = 256; + private byte[] _buffer; + private int _written; - // 2. Primitive coercion (int/long/double mismatches from MessagePack typeless decode) - if (raw is IConvertible) + internal PooledBufferWriter() { - try { return (T)Convert.ChangeType(raw, typeof(T)); } - catch (InvalidCastException) { } - catch (FormatException) { } + _buffer = ArrayPool.Shared.Rent(InitialSize); + _written = 0; } - // 3. [MessagePackObject] types: re-encode the raw value and decode as T - // Raw arrived via typeless deserialiser (e.g. as object[] for array-keyed objects). - // MessagePack's standard resolver handles [MessagePackObject] correctly. - var bytes = MessagePackSerializer.Serialize(raw); - return MessagePackSerializer.Deserialize(bytes); + internal ReadOnlyMemory WrittenMemory => new ReadOnlyMemory(_buffer, 0, _written); + + public void Advance(int count) => _written += count; + + public Memory GetMemory(int sizeHint = 0) + { + Grow(sizeHint); + return new Memory(_buffer, _written, _buffer.Length - _written); + } + + public Span GetSpan(int sizeHint = 0) + { + Grow(sizeHint); + return new Span(_buffer, _written, _buffer.Length - _written); + } + + private void Grow(int sizeHint) + { + int required = _written + Math.Max(sizeHint, 1); + if (required <= _buffer.Length) return; + + int newSize = Math.Max(required, _buffer.Length * 2); + var next = ArrayPool.Shared.Rent(newSize); + Buffer.BlockCopy(_buffer, 0, next, 0, _written); + ArrayPool.Shared.Return(_buffer); + _buffer = next; + } + + public void Dispose() + { + if (_buffer != null) + { + ArrayPool.Shared.Return(_buffer); + _buffer = null; + } + } } } } From 361b5ca8b3bea6890e0248332724be3098e85a5a Mon Sep 17 00:00:00 2001 From: Erik Hahne Tensaye Date: Sat, 28 Feb 2026 16:31:09 +0100 Subject: [PATCH 07/10] Optimize Server.cs --- src/Netler/Netler.csproj | 3 +- src/Netler/Server.cs | 125 +++++++++++++++++++++++++-------------- 2 files changed, 84 insertions(+), 44 deletions(-) diff --git a/src/Netler/Netler.csproj b/src/Netler/Netler.csproj index 5886a43..1a6cd1b 100644 --- a/src/Netler/Netler.csproj +++ b/src/Netler/Netler.csproj @@ -1,7 +1,8 @@ - netstandard2.0 + netstandard2.0;net6.0 + latest Netler.NET Svan Jansson MIT diff --git a/src/Netler/Server.cs b/src/Netler/Server.cs index 57a3b9c..b15ae08 100644 --- a/src/Netler/Server.cs +++ b/src/Netler/Server.cs @@ -13,7 +13,7 @@ namespace Netler /// /// A Netler Server listens to incoming TCP requests and translates them into method calls /// - public class Server + public partial class Server { private readonly IConfiguration _configuration; private CancellationTokenSource _cancellationSource; @@ -62,9 +62,8 @@ private async Task StartServerAsync(CancellationToken ct) var listener = new TcpListener(IPAddress.Loopback, port); listener.Start(); - ct.Register(() => listener.Stop()); - logger.LogInformation("Netler server listening on port {Port}", port); + LogListening(logger, port); if (clientPid != null) { @@ -78,75 +77,86 @@ private async Task StartServerAsync(CancellationToken ct) TcpClient tcpClient; try { - tcpClient = await listener.AcceptTcpClientAsync(); +#if NET6_0_OR_GREATER + tcpClient = await listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); +#else + // Optimization: dispose the registration once AcceptTcpClientAsync returns + // so the CT callback cannot fire after the listener is already in use. + using var registration = ct.Register(() => listener.Stop()); + tcpClient = await listener.AcceptTcpClientAsync().ConfigureAwait(false); +#endif } catch (SocketException) when (ct.IsCancellationRequested) { - logger.LogInformation("Netler server stopped before accepting a connection"); + LogStoppedBeforeConnect(logger); + return this; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + LogStoppedBeforeConnect(logger); return this; } - logger.LogInformation("Client connected"); + LogClientConnected(logger); - using (tcpClient) - { - var stream = tcpClient.GetStream(); + using var client = tcpClient; + var stream = client.GetStream(); - while (!ct.IsCancellationRequested) + while (!ct.IsCancellationRequested) + { + try { + var encodedRequest = await stream.ReadWithHeaderAsync(ct).ConfigureAwait(false); + var request = Request.Decode(encodedRequest); + + LogRequest(logger, request.Route); + try { - var encodedRequest = await stream.ReadWithHeaderAsync(ct); - var request = Request.Decode(encodedRequest); - - logger.LogDebug("Received request for route {Route}", request.Route); - - try - { - var methodResponse = routes.Invoke(request.Route, request.Parameters); - var response = new Response(Response.Code.Ok, methodResponse); - await stream.WriteWithHeaderAsync(response.Encode(), ct); - } - catch (RouteMethodCallFailed ex) - { - logger.LogError(ex, "Route {Route} threw an exception", request.Route); - var response = new Response(Response.Code.Error, ex.InnerException.Message); - await stream.WriteWithHeaderAsync(response.Encode(), ct); - } - } - catch (OperationCanceledException) - { - break; - } - catch (System.IO.EndOfStreamException) - { - // Client closed the connection - break; + var methodResponse = routes.Invoke(request.Route, request.Parameters); + var response = new Response(Response.Code.Ok, methodResponse); + await stream.WriteWithHeaderAsync(response.Encode(), ct).ConfigureAwait(false); } - catch (Exception ex) when (!ct.IsCancellationRequested) + catch (RouteMethodCallFailed ex) { - logger.LogError(ex, "Unexpected error in server loop"); - break; + LogRouteError(logger, ex, request.Route); + var response = new Response(Response.Code.Error, ex.InnerException.Message); + await stream.WriteWithHeaderAsync(response.Encode(), ct).ConfigureAwait(false); } } + catch (OperationCanceledException) + { + break; + } + catch (System.IO.EndOfStreamException) + { + // Client closed the connection + break; + } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + LogUnexpectedError(logger, ex); + break; + } } - logger.LogInformation("Netler server stopped"); + LogServerStopped(logger); return this; } private void StartCheckingIfClientIsAlive(int clientPid, ClientDisconnectBehaviour behaviour, CancellationToken ct, ILogger logger) - => Task.Run(async () => + { + _ = Task.Run(async () => { while (!ct.IsCancellationRequested && ClientIsAlive(clientPid)) { - try { await Task.Delay(500, ct); } + try { await Task.Delay(500, ct).ConfigureAwait(false); } catch (OperationCanceledException) { return; } } if (ct.IsCancellationRequested) return; - logger.LogInformation("Client process {Pid} has disconnected. Behaviour: {Behaviour}", clientPid, behaviour); + LogClientDisconnected(logger, clientPid, behaviour); switch (behaviour) { @@ -161,6 +171,7 @@ private void StartCheckingIfClientIsAlive(int clientPid, ClientDisconnectBehavio break; } }, ct); + } private bool ClientIsAlive(int clientPid) { @@ -174,5 +185,33 @@ private bool ClientIsAlive(int clientPid) return false; } } + + // ── [LoggerMessage] source-generated log delegates ─────────────────────── + // Pre-compiled at build time by the Microsoft.Extensions.Logging.Abstractions + // source generator — no runtime string-format parsing, no argument boxing. + + [LoggerMessage(Level = LogLevel.Information, Message = "Netler server listening on port {Port}")] + private static partial void LogListening(ILogger logger, int port); + + [LoggerMessage(Level = LogLevel.Information, Message = "Netler server stopped before accepting a connection")] + private static partial void LogStoppedBeforeConnect(ILogger logger); + + [LoggerMessage(Level = LogLevel.Information, Message = "Client connected")] + private static partial void LogClientConnected(ILogger logger); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Received request for route {Route}")] + private static partial void LogRequest(ILogger logger, string route); + + [LoggerMessage(Level = LogLevel.Error, Message = "Route {Route} threw an exception")] + private static partial void LogRouteError(ILogger logger, Exception exception, string route); + + [LoggerMessage(Level = LogLevel.Error, Message = "Unexpected error in server loop")] + private static partial void LogUnexpectedError(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Information, Message = "Netler server stopped")] + private static partial void LogServerStopped(ILogger logger); + + [LoggerMessage(Level = LogLevel.Information, Message = "Client process {Pid} has disconnected. Behaviour: {Behaviour}")] + private static partial void LogClientDisconnected(ILogger logger, int pid, ClientDisconnectBehaviour behaviour); } } From af7564ce3ea7f32302066c2cc14e4d66394c2b8e Mon Sep 17 00:00:00 2001 From: Erik Hahne Tensaye Date: Sat, 28 Feb 2026 16:32:12 +0100 Subject: [PATCH 08/10] Net 8 --- src/Netler/Netler.csproj | 2 +- src/Netler/Server.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Netler/Netler.csproj b/src/Netler/Netler.csproj index 1a6cd1b..47bab6f 100644 --- a/src/Netler/Netler.csproj +++ b/src/Netler/Netler.csproj @@ -1,7 +1,7 @@ - netstandard2.0;net6.0 + netstandard2.0;net8.0 latest Netler.NET Svan Jansson diff --git a/src/Netler/Server.cs b/src/Netler/Server.cs index b15ae08..a62a3a4 100644 --- a/src/Netler/Server.cs +++ b/src/Netler/Server.cs @@ -77,7 +77,7 @@ private async Task StartServerAsync(CancellationToken ct) TcpClient tcpClient; try { -#if NET6_0_OR_GREATER +#if NET8_0_OR_GREATER tcpClient = await listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); #else // Optimization: dispose the registration once AcceptTcpClientAsync returns From f9ee060a81d52ef185a32771364ccd2dcfc20487 Mon Sep 17 00:00:00 2001 From: Erik Hahne Tensaye Date: Sat, 28 Feb 2026 16:48:22 +0100 Subject: [PATCH 09/10] Update docs --- DOCS.md | 954 ------------------ README.md | 164 ++- src/Netler/Client.cs | 106 +- src/Netler/Server.cs | 69 +- src/Netler/Server/Contracts/IConfiguration.cs | 85 +- src/Netler/Server/Contracts/IRoutes.cs | 49 +- 6 files changed, 395 insertions(+), 1032 deletions(-) delete mode 100644 DOCS.md diff --git a/DOCS.md b/DOCS.md deleted file mode 100644 index 7023a85..0000000 --- a/DOCS.md +++ /dev/null @@ -1,954 +0,0 @@ - -# Netler - -## Contents - -- [Client](#T-Netler-Client 'Netler.Client') - - [#ctor(port)](#M-Netler-Client-#ctor-System-Int32- 'Netler.Client.#ctor(System.Int32)') - - [#ctor(port,hostname)](#M-Netler-Client-#ctor-System-String,System-Int32- 'Netler.Client.#ctor(System.String,System.Int32)') - - [Dispose()](#M-Netler-Client-Dispose 'Netler.Client.Dispose') - - [Invoke(route,parameters)](#M-Netler-Client-Invoke-System-String,System-Object[]- 'Netler.Client.Invoke(System.String,System.Object[])') - - [InvokeAsync(route,parameters,cancellationToken)](#M-Netler-Client-InvokeAsync-System-String,System-Object[],System-Threading-CancellationToken- 'Netler.Client.InvokeAsync(System.String,System.Object[],System.Threading.CancellationToken)') - - [InvokeAsync\`\`1(route,parameters,cancellationToken)](#M-Netler-Client-InvokeAsync``1-System-String,System-Object[],System-Threading-CancellationToken- 'Netler.Client.InvokeAsync``1(System.String,System.Object[],System.Threading.CancellationToken)') -- [Params](#T-Netler-Params 'Netler.Params') - - [Decode overloads (Func/Action, 0–4 params)](#M-Netler-Params-Decode 'Netler.Params.Decode') -- [TypedRouteExtensions](#T-Netler-Contracts-TypedRouteExtensions 'Netler.Contracts.TypedRouteExtensions') - - [AddTyped overloads (Func/Action, 0–4 params)](#M-Netler-Contracts-TypedRouteExtensions-AddTyped 'Netler.Contracts.TypedRouteExtensions.AddTyped') -- [ClientDisconnectBehaviour](#T-Netler-Contracts-ClientDisconnectBehaviour 'Netler.Contracts.ClientDisconnectBehaviour') - - [DisposeServer](#F-Netler-Contracts-ClientDisconnectBehaviour-DisposeServer 'Netler.Contracts.ClientDisconnectBehaviour.DisposeServer') - - [KeepAlive](#F-Netler-Contracts-ClientDisconnectBehaviour-KeepAlive 'Netler.Contracts.ClientDisconnectBehaviour.KeepAlive') - - [ShutdownApplication](#F-Netler-Contracts-ClientDisconnectBehaviour-ShutdownApplication 'Netler.Contracts.ClientDisconnectBehaviour.ShutdownApplication') -- [Code](#T-Netler-Response-Code 'Netler.Response.Code') - - [Error](#F-Netler-Response-Code-Error 'Netler.Response.Code.Error') - - [Ok](#F-Netler-Response-Code-Ok 'Netler.Response.Code.Ok') -- [Configuration](#T-Netler-Configuration 'Netler.Configuration') -- [IConfiguration](#T-Netler-Contracts-IConfiguration 'Netler.Contracts.IConfiguration') - - [GetClientDisconnectBehaviour()](#M-Netler-Contracts-IConfiguration-GetClientDisconnectBehaviour 'Netler.Contracts.IConfiguration.GetClientDisconnectBehaviour') - - [GetClientPid()](#M-Netler-Contracts-IConfiguration-GetClientPid 'Netler.Contracts.IConfiguration.GetClientPid') - - [GetPort()](#M-Netler-Contracts-IConfiguration-GetPort 'Netler.Contracts.IConfiguration.GetPort') - - [GetRoutes()](#M-Netler-Contracts-IConfiguration-GetRoutes 'Netler.Contracts.IConfiguration.GetRoutes') - - [UseClientDisconnectBehaviour()](#M-Netler-Contracts-IConfiguration-UseClientDisconnectBehaviour-Netler-Contracts-ClientDisconnectBehaviour- 'Netler.Contracts.IConfiguration.UseClientDisconnectBehaviour(Netler.Contracts.ClientDisconnectBehaviour)') - - [UseClientPid()](#M-Netler-Contracts-IConfiguration-UseClientPid-System-Int32- 'Netler.Contracts.IConfiguration.UseClientPid(System.Int32)') - - [UseLogger(logger)](#M-Netler-Contracts-IConfiguration-UseLogger-Microsoft-Extensions-Logging-ILogger- 'Netler.Contracts.IConfiguration.UseLogger(Microsoft.Extensions.Logging.ILogger)') - - [UsePort()](#M-Netler-Contracts-IConfiguration-UsePort-System-Int32- 'Netler.Contracts.IConfiguration.UsePort(System.Int32)') - - [UseRoutes(routes)](#M-Netler-Contracts-IConfiguration-UseRoutes-System-Action{Netler-Contracts-IRoutes}- 'Netler.Contracts.IConfiguration.UseRoutes(System.Action{Netler.Contracts.IRoutes})') -- [IRoutes](#T-Netler-Contracts-IRoutes 'Netler.Contracts.IRoutes') - - [Add(route,method)](#M-Netler-Contracts-IRoutes-Add-System-String,System-Func{System-Object[],System-Object}- 'Netler.Contracts.IRoutes.Add(System.String,System.Func{System.Object[],System.Object})') - - [Invoke(route,parameters)](#M-Netler-Contracts-IRoutes-Invoke-System-String,System-Object[]- 'Netler.Contracts.IRoutes.Invoke(System.String,System.Object[])') -- [InvalidDataType](#T-Netler-Exceptions-InvalidDataType 'Netler.Exceptions.InvalidDataType') - - [#ctor(message)](#M-Netler-Exceptions-InvalidDataType-#ctor-System-String- 'Netler.Exceptions.InvalidDataType.#ctor(System.String)') -- [MalformedResponseBytes](#T-Netler-Exceptions-MalformedResponseBytes 'Netler.Exceptions.MalformedResponseBytes') - - [#ctor(message)](#M-Netler-Exceptions-MalformedResponseBytes-#ctor-System-String- 'Netler.Exceptions.MalformedResponseBytes.#ctor(System.String)') -- [Message](#T-Netler-Message 'Netler.Message') -- [RemoteInvokationFailed](#T-Netler-Exceptions-RemoteInvokationFailed 'Netler.Exceptions.RemoteInvokationFailed') - - [#ctor(message)](#M-Netler-Exceptions-RemoteInvokationFailed-#ctor-System-String- 'Netler.Exceptions.RemoteInvokationFailed.#ctor(System.String)') -- [Request](#T-Netler-Request 'Netler.Request') - - [#ctor(route,parameters)](#M-Netler-Request-#ctor-System-String,System-Object[]- 'Netler.Request.#ctor(System.String,System.Object[])') - - [Parameters](#P-Netler-Request-Parameters 'Netler.Request.Parameters') - - [Route](#P-Netler-Request-Route 'Netler.Request.Route') - - [Decode()](#M-Netler-Request-Decode-System-Byte[]- 'Netler.Request.Decode(System.Byte[])') - - [Encode()](#M-Netler-Request-Encode 'Netler.Request.Encode') - - [Equals(other)](#M-Netler-Request-Equals-System-Object- 'Netler.Request.Equals(System.Object)') - - [GetHashCode()](#M-Netler-Request-GetHashCode 'Netler.Request.GetHashCode') - - [op_Equality()](#M-Netler-Request-op_Equality-Netler-Request,Netler-Request- 'Netler.Request.op_Equality(Netler.Request,Netler.Request)') - - [op_Inequality()](#M-Netler-Request-op_Inequality-Netler-Request,Netler-Request- 'Netler.Request.op_Inequality(Netler.Request,Netler.Request)') -- [Response](#T-Netler-Response 'Netler.Response') - - [#ctor(status,data)](#M-Netler-Response-#ctor-Netler-Response-Code,System-Object- 'Netler.Response.#ctor(Netler.Response.Code,System.Object)') - - [Data](#P-Netler-Response-Data 'Netler.Response.Data') - - [Status](#P-Netler-Response-Status 'Netler.Response.Status') - - [Decode()](#M-Netler-Response-Decode-System-Byte[]- 'Netler.Response.Decode(System.Byte[])') - - [Encode()](#M-Netler-Response-Encode 'Netler.Response.Encode') - - [Equals(other)](#M-Netler-Response-Equals-System-Object- 'Netler.Response.Equals(System.Object)') - - [FromNamed\`\`1(status,data)](#M-Netler-Response-FromNamed``1-Netler-Response-Code,``0- 'Netler.Response.FromNamed``1(Netler.Response.Code,``0)') - - [GetHashCode()](#M-Netler-Response-GetHashCode 'Netler.Response.GetHashCode') - - [op_Equality()](#M-Netler-Response-op_Equality-Netler-Response,Netler-Response- 'Netler.Response.op_Equality(Netler.Response,Netler.Response)') - - [op_Inequality()](#M-Netler-Response-op_Inequality-Netler-Response,Netler-Response- 'Netler.Response.op_Inequality(Netler.Response,Netler.Response)') -- [RouteAlreadyDefined](#T-Netler-Exceptions-RouteAlreadyDefined 'Netler.Exceptions.RouteAlreadyDefined') - - [#ctor(message)](#M-Netler-Exceptions-RouteAlreadyDefined-#ctor-System-String- 'Netler.Exceptions.RouteAlreadyDefined.#ctor(System.String)') -- [RouteMethodCallFailed](#T-Netler-Exceptions-RouteMethodCallFailed 'Netler.Exceptions.RouteMethodCallFailed') - - [#ctor(message,inner)](#M-Netler-Exceptions-RouteMethodCallFailed-#ctor-System-String,System-Exception- 'Netler.Exceptions.RouteMethodCallFailed.#ctor(System.String,System.Exception)') -- [RouteUndefined](#T-Netler-Exceptions-RouteUndefined 'Netler.Exceptions.RouteUndefined') - - [#ctor(message)](#M-Netler-Exceptions-RouteUndefined-#ctor-System-String- 'Netler.Exceptions.RouteUndefined.#ctor(System.String)') -- [Routes](#T-Netler-Routes 'Netler.Routes') - - [Add(route,method)](#M-Netler-Routes-Add-System-String,System-Func{System-Object[],System-Object}- 'Netler.Routes.Add(System.String,System.Func{System.Object[],System.Object})') - - [Invoke(route,parameters)](#M-Netler-Routes-Invoke-System-String,System-Object[]- 'Netler.Routes.Invoke(System.String,System.Object[])') -- [Server](#T-Netler-Server 'Netler.Server') - - [Create(configure)](#M-Netler-Server-Create-System-Action{Netler-Contracts-IConfiguration}- 'Netler.Server.Create(System.Action{Netler.Contracts.IConfiguration})') - - [Start(cancellationToken)](#M-Netler-Server-Start-System-Threading-CancellationToken- 'Netler.Server.Start(System.Threading.CancellationToken)') - - [Stop()](#M-Netler-Server-Stop 'Netler.Server.Stop') - - -## Client `type` - -##### Namespace - -Netler - -##### Summary - -Client for calling a Netler server over a specific TCP port - - -### #ctor(port) `constructor` - -##### Summary - -Create a new client to a localhost server listing on a given TCP port - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| port | [System.Int32](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Int32 'System.Int32') | A valid TCP port | - - -### #ctor(port,hostname) `constructor` - -##### Summary - -Create a new client to a named server listing on a given TCP port - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| port | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | A valid TCP port | -| hostname | [System.Int32](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Int32 'System.Int32') | A valid hostname | - - -### Dispose() `method` - -##### Summary - - - -##### Parameters - -This method has no parameters. - - -### Invoke(route,parameters) `method` - -##### Summary - -Invokes a method on the Netler server using its route - -##### Returns - - - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| route | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | The name of the route | -| parameters | [System.Object[]](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Object[] 'System.Object[]') | The parameters to pass to the method | - - -### InvokeAsync\`\`1(route,parameters,cancellationToken) `method` - -##### Summary - -Asynchronously invokes a method on the Netler server and deserialises the result to the requested type - -##### Returns - -The typed return value of the remote method - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| route | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | The name of the route | -| parameters | [System.Object[]](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Object[] 'System.Object[]') | The parameters to pass to the method | -| cancellationToken | [System.Threading.CancellationToken](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Threading.CancellationToken 'System.Threading.CancellationToken') | Token to cancel the operation | - -##### Generic Types - -| Name | Description | -| ---- | ----------- | -| T | The expected return type. Primitives are coerced via `Convert.ChangeType`; complex types must be annotated with `[MessagePackObject]`. | - - -### InvokeAsync(route,parameters,cancellationToken) `method` - -##### Summary - -Asynchronously invokes a method on the Netler server using its route - -##### Returns - -The return value of the remote method - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| route | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | The name of the route | -| parameters | [System.Object[]](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Object[] 'System.Object[]') | The parameters to pass to the method | -| cancellationToken | [System.Threading.CancellationToken](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Threading.CancellationToken 'System.Threading.CancellationToken') | Token to cancel the operation | - - -## ClientDisconnectBehaviour `type` - -##### Namespace - -Netler.Contracts - -##### Summary - -Describes what should happen when the connected client disconnects from the server - - -### DisposeServer `constants` - -##### Summary - -Disposes of the current Netler server instance but keeps the application running - - -### KeepAlive `constants` - -##### Summary - -Keeps the server alive to accept new client connections - - -### ShutdownApplication `constants` - -##### Summary - -Shuts down the entire application that's hosting the server (default) - - -## Code `type` - -##### Namespace - -Netler.Response - -##### Summary - -The status of a Netler response - - -### Error `constants` - -##### Summary - -The method invokation failed - - -### Ok `constants` - -##### Summary - -The method invokation succeeded - - -## Configuration `type` - -##### Namespace - -Netler - -##### Summary - -Default configuration parameters for a Netler Server - - -## IConfiguration `type` - -##### Namespace - -Netler.Contracts - -##### Summary - -Configuration parameters for starting a Netler Server - - -### GetClientDisconnectBehaviour() `method` - -##### Summary - -The currently configured client disconnect behaviour - -##### Parameters - -This method has no parameters. - - -### GetClientPid() `method` - -##### Summary - -The currently configured client pid - -##### Parameters - -This method has no parameters. - - -### GetPort() `method` - -##### Summary - -The currently configured port - -##### Parameters - -This method has no parameters. - - -### GetRoutes() `method` - -##### Summary - -The currently configured routes - -##### Parameters - -This method has no parameters. - - -### UseClientDisconnectBehaviour() `method` - -##### Summary - -By passing a client OS pid the Netler Server will automatically react when a client disconnects -(default by shutting down the entire application). Use to change the behaviour. - -##### Parameters - -This method has no parameters. - - -### UseClientPid() `method` - -##### Summary - -By passing a client OS pid the Netler Server will automatically shut down when the client shuts down - -##### Parameters - -This method has no parameters. - - -### UseLogger(logger) `method` - -##### Summary - -Configures an `ILogger` for the server to write diagnostic messages to. Defaults to `NullLogger` when not set. - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| logger | Microsoft.Extensions.Logging.ILogger | The logger instance | - - -### UsePort() `method` - -##### Summary - -Which port the Netler Server should listen to - -##### Parameters - -This method has no parameters. - - -### UseRoutes(routes) `method` - -##### Summary - -Which routes the Netler Server should expose - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| routes | [System.Action{Netler.Contracts.IRoutes}](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Action 'System.Action{Netler.Contracts.IRoutes}') | A mapping of string routes to methods that are executed when the route is called | - - -## Params `type` - -##### Namespace - -Netler - -##### Summary - -Functional composition helper that wraps a typed delegate into the `Func` signature expected by `IRoutes.Add`. Each `Decode` overload returns the adapter function directly, so it composes cleanly at the point of route registration. Parameter decoding uses a three-step strategy: direct cast → `Convert.ChangeType` (primitive coercion) → MessagePack round-trip (for complex types with `[MessagePackObject]`). - -##### Example - -```csharp -routes.Add("Add", Params.Decode((a, b) => a + b)); -routes.Add("Double", Params.Decode(x => x * 2)); -routes.Add("Ping", Params.Decode(() => "pong")); -routes.Add("Log", Params.Decode(msg => { /* void */ })); -routes.Add("Create", Params.Decode(req => new CreateResponse { Id = 1 })); -``` - - -### Decode overloads `method` - -##### Summary - -Returns a `Func` that decodes the raw MessagePack parameter array, calls the provided typed delegate, and returns the result boxed as `object` (or `null` for `Action` overloads). Overloads exist for 0–4 parameters and for both `Func<…, TResult>` (returns a value) and `Action<…>` (void) delegates. - -| Signature | Description | -|---|---| -| `Decode(Func)` | 0 params, returns value | -| `Decode(Func)` | 1 param, returns value | -| `Decode(Func)` | 2 params, returns value | -| `Decode(Func)` | 3 params, returns value | -| `Decode(Func)` | 4 params, returns value | -| `Decode(Action)` | 0 params, void | -| `Decode(Action)` | 1 param, void | -| `Decode(Action)` | 2 params, void | -| `Decode(Action)` | 3 params, void | -| `Decode(Action)` | 4 params, void | - - -## IRoutes `type` - -##### Namespace - -Netler.Contracts - -##### Summary - -Message routing for a Netler Server - - -### Add(route,method) `method` - -##### Summary - -Adds a new route to the route table - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| route | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | The exposed name of the route. Example: "CreateNewFoo" | -| method | [System.Func{System.Object[],System.Object}](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Func 'System.Func{System.Object[],System.Object}') | A method to execute for calls to the route | - - -### Invoke(route,parameters) `method` - -##### Summary - -Invokes the method that is linked to a route - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| route | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | The exposed name of the route. Example: "CreateNewFoo" | -| parameters | [System.Object[]](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Object[] 'System.Object[]') | A list of parameters to pass to the method | - - -## InvalidDataType `type` - -##### Namespace - -Netler.Exceptions - -##### Summary - -Thrown when trying to encode an object to the Netler binary message format and it fails - - -### #ctor(message) `constructor` - -##### Summary - -Creates a new instance of the exception with a message describing the details of the error - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| message | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | A message describing the error | - - -## MalformedResponseBytes `type` - -##### Namespace - -Netler.Exceptions - -##### Summary - -Thrown when trying to decode a [Response](#T-Netler-Response 'Netler.Response') from a byte array that is malformed - - -### #ctor(message) `constructor` - -##### Summary - -Creates a new instance of the exception with a message describing the details of the error - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| message | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | A message describing the error | - - -## Message `type` - -##### Namespace - -Netler - -##### Summary - -Module for encoding/decoding messages for use with Netler clients and servers - - -## RemoteInvokationFailed `type` - -##### Namespace - -Netler.Exceptions - -##### Summary - -Thrown from the [Client](#T-Netler-Client 'Netler.Client') when the remote invokation throws an exception on the server - - -### #ctor(message) `constructor` - -##### Summary - -Creates a new instance of the exception with a message describing the details of the error - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| message | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | A message describing the error | - - -## Request `type` - -##### Namespace - -Netler - -##### Summary - -A request from a Netler Client to a Netler Server - - -### #ctor(route,parameters) `constructor` - -##### Summary - -Creates a new request value object - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| route | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | The route for the request | -| parameters | [System.Object[]](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Object[] 'System.Object[]') | The parameters to pass to the method invokation | - - -### Parameters `property` - -##### Summary - -The parameters for the method at the server route - - -### Route `property` - -##### Summary - -The server route of the request - - -### Decode() `method` - -##### Summary - -Decodes a request from the Netler transport format - -##### Returns - -A byte array containing a Netler request encoded in the Netler transport format - -##### Parameters - -This method has no parameters. - - -### Encode() `method` - -##### Summary - -Encodes the request to the Netler transport format - -##### Returns - -A byte array that can be parsed by a Netler server - -##### Parameters - -This method has no parameters. - - -### Equals(other) `method` - -##### Summary - - - -##### Returns - -True if the objects are equal - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| other | [System.Object](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Object 'System.Object') | The object to compare with the current object. | - - -### GetHashCode() `method` - -##### Summary - - - -##### Parameters - -This method has no parameters. - - -### op_Equality() `method` - -##### Summary - - - -##### Parameters - -This method has no parameters. - - -### op_Inequality() `method` - -##### Summary - -Determines whether the specified object is different from the current object - -##### Parameters - -This method has no parameters. - - -## Response `type` - -##### Namespace - -Netler - -##### Summary - -A response from a Netler Server - - -### #ctor(status,data) `constructor` - -##### Summary - -Creates a [Response](#T-Netler-Response 'Netler.Response') with a status and an anonymously typed data payload - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| status | [Netler.Response.Code](#T-Netler-Response-Code 'Netler.Response.Code') | The response status code | -| data | [System.Object](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Object 'System.Object') | The anonymous data payload | - - -### Data `property` - -##### Summary - -The response data payload - - -### Status `property` - -##### Summary - -The status code of the message ivokation at the Netler server - - -### Decode() `method` - -##### Summary - -Decodes a response from the Netler transport format - -##### Returns - -A byte array containing a Netler Response encoded in the Netler transport format - -##### Parameters - -This method has no parameters. - - -### Encode() `method` - -##### Summary - -Encodes the response to the Netler transport format - -##### Returns - -A byte array that can be parsed by a Netler server - -##### Parameters - -This method has no parameters. - - -### Equals(other) `method` - -##### Summary - - - -##### Returns - -True if the objects are equal - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| other | [System.Object](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Object 'System.Object') | The object to compare with the current object. | - - -### FromNamed\`\`1(status,data) `method` - -##### Summary - -Creates a [Response](#T-Netler-Response 'Netler.Response') with a status and an named data payload. -Using this constructor has a major impact on transport performance, as it uses reflection to convert the type into a serializable anonymous object. - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| status | [Netler.Response.Code](#T-Netler-Response-Code 'Netler.Response.Code') | The response status code | -| data | [\`\`0](#T-``0 '``0') | The data payload | - -##### Generic Types - -| Name | Description | -| ---- | ----------- | -| T | The type of the `data` parameter | - - -### GetHashCode() `method` - -##### Summary - - - -##### Parameters - -This method has no parameters. - - -### op_Equality() `method` - -##### Summary - - - -##### Parameters - -This method has no parameters. - - -### op_Inequality() `method` - -##### Summary - -Determines whether the specified object is different from the current object - -##### Parameters - -This method has no parameters. - - -## RouteAlreadyDefined `type` - -##### Namespace - -Netler.Exceptions - -##### Summary - -Thrown when trying to add a [Routes](#T-Netler-Routes 'Netler.Routes') route and it already exists in the route table - - -### #ctor(message) `constructor` - -##### Summary - -Creates a new instance of the exception with a message describing the details of the error - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| message | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | A message describing the error | - - -## RouteMethodCallFailed `type` - -##### Namespace - -Netler.Exceptions - -##### Summary - -Thrown when an invoking a method on a [Routes](#T-Netler-Routes 'Netler.Routes') route and it throws any unhandled exception - - -### #ctor(message,inner) `constructor` - -##### Summary - -Creates a new instance of the exception with a message describing the details of the error - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| message | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | A message describing the error | -| inner | [System.Exception](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Exception 'System.Exception') | An inner exception containing details of the caught error | - - -## RouteUndefined `type` - -##### Namespace - -Netler.Exceptions - -##### Summary - -Thrown when trying to invoke a method on a [Routes](#T-Netler-Routes 'Netler.Routes') route and it doesn't exist in the route table - - -### #ctor(message) `constructor` - -##### Summary - -Creates a new instance of the exception with a message describing the details of the error - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| message | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | A message describing the error | - - -## Routes `type` - -##### Namespace - -Netler - -##### Summary - -Message routing for a Netler Server - - -### Add(route,method) `method` - -##### Summary - -Adds a new route to the route table - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| route | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | The exposed name of the route. Example: "CreateNewFoo" | -| method | [System.Func{System.Object[],System.Object}](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Func 'System.Func{System.Object[],System.Object}') | A method to execute for calls to the route | - - -### Invoke(route,parameters) `method` - -##### Summary - -Invokes the method that is linked to a route - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| route | [System.String](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.String 'System.String') | The exposed name of the route. Example: "CreateNewFoo" | -| parameters | [System.Object[]](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Object[] 'System.Object[]') | A list of parameters to pass to the method | - - -## Server `type` - -##### Namespace - -Netler - -##### Summary - -A Netler Server listens to incoming TCP requests and translates them into method calls - - -### Create(configure) `method` - -##### Summary - -Creates new Netler Server instance - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| configure | [System.Action{Netler.Contracts.IConfiguration}](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Action 'System.Action{Netler.Contracts.IConfiguration}') | Callback for configuring the server instance | - - -### Start(cancellationToken) `method` - -##### Summary - -Starts the Netler Server - -##### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| cancellationToken | [System.Threading.CancellationToken](http://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k:System.Threading.CancellationToken 'System.Threading.CancellationToken') | Token to cancel the server (optional) | - - -### Stop() `method` - -##### Summary - -Stops the Netler Server - -##### Parameters - -This method has no parameters. diff --git a/README.md b/README.md index 171bad4..71d6501 100644 --- a/README.md +++ b/README.md @@ -7,53 +7,165 @@ # Netler.NET -A library for cross-process method calls over TCP. For instance for calling .NET methods from Elixir (see [https://hexdocs.pm/netler/](https://hexdocs.pm/netler/)). All messages are serialized using the fast and light [MessagePack](https://msgpack.org) binary format. The library also includes a client that can be used to call a Netler server from another .NET application. +A library for cross-process method calls over TCP. Designed for calling .NET methods from Elixir (see [hexdocs.pm/netler](https://hexdocs.pm/netler/)), and usable anywhere you need fast binary RPC between processes. All messages are serialised with [MessagePack](https://msgpack.org). -## Documentation +## Getting Started -Auto generated documentation is available here: [DOCS.md](DOCS.md) - -## Code Examples +```sh +dotnet add package Netler.NET +``` -### Creating a Server That Exposes a Method +## Server -This snippet creates a `Netler.Server` that listens on port `5544`. It exposes a single anonymous method on route `Add`. +### Basic route registration ```csharp using Netler; -... - -var server = Server.Create((config) => +var server = Server.Create(config => +{ + config.UsePort(5544); + config.UseRoutes(routes => { - config.UsePort(5544); - config.UseRoutes((routes) => + routes.Add("Add", param => { - routes.Add("Add", (param) => - { - var a = Convert.ToInt32(param[0]); - var b = Convert.ToInt32(param[1]); - return a + b; - }); - // More routes can be added here ... + var a = Convert.ToInt32(param[0]); + var b = Convert.ToInt32(param[1]); + return a + b; }); }); +}); -server.Start(); +await server.Start(); ``` -### Calling a Method on The Server +### Typed routes with `AddTyped` -This snippet calls the method in the example above. The client, in this example, is running on the same machine as the server, but in a different process. +`AddTyped` infers parameter and return types from the lambda. The C# compiler enforces the types; the wire format is unchanged. + +```csharp +config.UseRoutes(routes => +{ + routes.AddTyped("Add", (int a, int b) => a + b); + routes.AddTyped("Double", (int x) => x * 2); + routes.AddTyped("Ping", () => "pong"); + routes.AddTyped("Log", (string msg) => { /* void handler */ }); +}); +``` + +### Explicit composition with `Params.Decode` + +`Params.Decode` wraps a typed delegate into the `Func` signature that `IRoutes.Add` expects. Useful when you want to separate the handler from the route registration, or pass an existing method reference. + +```csharp +static int Add(int a, int b) => a + b; + +config.UseRoutes(routes => +{ + routes.Add("Add", Params.Decode(Add)); + routes.Add("Double", Params.Decode((int x) => x * 2)); + routes.Add("Ping", Params.Decode(() => "pong")); +}); +``` + +### Complex types + +Any type annotated with `[MessagePackObject]` can be used as a parameter or return value. + +```csharp +using MessagePack; + +[MessagePackObject] +public class EchoRequest { [Key(0)] public string Text { get; set; } } + +[MessagePackObject] +public class EchoResponse { [Key(0)] public string Message { get; set; } + [Key(1)] public int Length { get; set; } } + +config.UseRoutes(routes => +{ + routes.AddTyped("Echo", (EchoRequest req) => + new EchoResponse { Message = req.Text, Length = req.Text.Length }); +}); +``` + +### Monitoring the client process + +The server can watch a client OS process and react automatically when it exits. + +```csharp +Server.Create(config => +{ + config.UsePort(5544); + config.UseClientPid(clientProcessId); + config.UseClientDisconnectBehaviour(ClientDisconnectBehaviour.DisposeServer); +}); +``` + +| `ClientDisconnectBehaviour` | Effect | +|---|---| +| `ShutdownApplication` | Calls `Environment.Exit(0)` — default | +| `DisposeServer` | Stops the server; application keeps running | +| `KeepAlive` | No action | + +### Custom logger + +```csharp +using Microsoft.Extensions.Logging; + +ILogger logger = loggerFactory.CreateLogger(); + +Server.Create(config => +{ + config.UsePort(5544); + config.UseLogger(logger); + config.UseRoutes(routes => { /* ... */ }); +}); +``` + +## Client + +### Typed calls (recommended) ```csharp using Netler; -... +using var client = new Client(5544); + +var sum = await client.InvokeAsync("Add", new object[] { 2, 3 }); +var pong = await client.InvokeAsync("Ping", new object[0]); + +// Complex return type — EchoResponse must carry [MessagePackObject] +var reply = await client.InvokeAsync("Echo", + new object[] { new EchoRequest { Text = "hello" } }); +``` + +### Untyped calls + +```csharp +var raw = await client.InvokeAsync("Add", new object[] { 2, 3 }); +var sum = Convert.ToInt32(raw); +``` + +### Remote to another machine + +```csharp +using var client = new Client("192.168.1.10", 5544); +``` + +### Error handling -using (var client = new Client(5544)) +When a route handler throws an unhandled exception on the server, the client receives a `RemoteInvokationFailed` exception containing the server-side error message. + +```csharp +using Netler.Exceptions; + +try +{ + await client.InvokeAsync("Divide", new object[] { 10, 0 }); +} +catch (RemoteInvokationFailed ex) { - var response = client.Invoke("Add", new object[] { 2, 3 }); - var responseAsInt = Convert.ToInt32(response); // The response is 5 + Console.WriteLine($"Server error: {ex.Message}"); } ``` diff --git a/src/Netler/Client.cs b/src/Netler/Client.cs index f00167a..80560ad 100644 --- a/src/Netler/Client.cs +++ b/src/Netler/Client.cs @@ -7,17 +7,31 @@ namespace Netler { /// - /// Client for calling a Netler server over a specific TCP port + /// Client for calling a Netler server over TCP. /// + /// + /// + /// using var client = new Client(5544); + /// + /// // Typed async call — return value is coerced to T automatically + /// var sum = await client.InvokeAsync<int>("Add", new object[] { 2, 3 }); + /// var pong = await client.InvokeAsync<string>("Ping", new object[0]); + /// + /// public class Client : IDisposable { private readonly TcpClient _tcpClient; private readonly NetworkStream _stream; /// - /// Create a new client to a localhost server listing on a given TCP port + /// Creates a client connected to a Netler server on localhost. /// - /// A valid TCP port + /// The TCP port the server is listening on. + /// + /// + /// using var client = new Client(5544); + /// + /// public Client(int port) { _tcpClient = new TcpClient("localhost", port); @@ -25,10 +39,15 @@ public Client(int port) } /// - /// Create a new client to a named server listing on a given TCP port + /// Creates a client connected to a Netler server on a remote host. /// - /// A valid TCP port - /// A valid hostname + /// The hostname or IP address of the server. + /// The TCP port the server is listening on. + /// + /// + /// using var client = new Client("192.168.1.10", 5544); + /// + /// public Client(string hostname, int port) { _tcpClient = new TcpClient(hostname, port); @@ -36,11 +55,20 @@ public Client(string hostname, int port) } /// - /// Invokes a method on the Netler server using its route + /// Synchronously invokes a route on the Netler server and returns the raw result. /// - /// The name of the route - /// The parameters to pass to the method - /// + /// The name of the route to invoke. + /// The parameters to pass to the route handler. + /// + /// The return value of the remote handler, boxed as . + /// Cast or convert to the expected type (e.g. Convert.ToInt32(result)). + /// + /// + /// + /// var result = client.Invoke("Add", new object[] { 2, 3 }); + /// var sum = Convert.ToInt32(result); // 5 + /// + /// public object Invoke(string route, object[] parameters) { var message = new Request(route, parameters); @@ -68,12 +96,25 @@ public object Invoke(string route, object[] parameters) } /// - /// Asynchronously invokes a method on the Netler server using its route + /// Asynchronously invokes a route on the Netler server and returns the raw result. /// - /// The name of the route - /// The parameters to pass to the method - /// Token to cancel the operation - /// The return value of the remote method + /// The name of the route to invoke. + /// The parameters to pass to the route handler. + /// An optional token to cancel the operation. + /// + /// The return value of the remote handler, boxed as . + /// Use to get a + /// strongly-typed result without a manual cast. + /// + /// + /// Thrown when the route handler throws an unhandled exception on the server. + /// + /// + /// + /// var raw = await client.InvokeAsync("Add", new object[] { 2, 3 }); + /// var sum = Convert.ToInt32(raw); + /// + /// public async Task InvokeAsync(string route, object[] parameters, CancellationToken cancellationToken = default) { var message = new Request(route, parameters); @@ -90,22 +131,39 @@ public async Task InvokeAsync(string route, object[] parameters, Cancell } /// - /// Asynchronously invokes a method on the Netler server using its route and deserialises the result to + /// Asynchronously invokes a route on the Netler server and deserialises the result to + /// . /// - /// The expected return type - /// The name of the route - /// The parameters to pass to the method - /// Token to cancel the operation - /// The typed return value of the remote method + /// + /// The expected return type. Primitive numeric types are coerced automatically (e.g. + /// a server returning long can be received as int). Complex types must + /// be annotated with [MessagePackObject] and their properties with [Key(n)]. + /// + /// The name of the route to invoke. + /// The parameters to pass to the route handler. + /// An optional token to cancel the operation. + /// The strongly-typed return value of the remote handler. + /// + /// Thrown when the route handler throws an unhandled exception on the server. + /// + /// + /// + /// // Primitive types + /// var sum = await client.InvokeAsync<int>("Add", new object[] { 2, 3 }); + /// var pong = await client.InvokeAsync<string>("Ping", new object[0]); + /// + /// // Complex type — EchoResponse must carry [MessagePackObject] + /// var reply = await client.InvokeAsync<EchoResponse>("Echo", + /// new object[] { new EchoRequest { Text = "hello" } }); + /// + /// public async Task InvokeAsync(string route, object[] parameters, CancellationToken cancellationToken = default) { var raw = await InvokeAsync(route, parameters, cancellationToken); return TypedConvert.To(raw); } - /// - /// - /// + /// public void Dispose() { _stream.Dispose(); diff --git a/src/Netler/Server.cs b/src/Netler/Server.cs index a62a3a4..8cc5243 100644 --- a/src/Netler/Server.cs +++ b/src/Netler/Server.cs @@ -11,8 +11,23 @@ namespace Netler { /// - /// A Netler Server listens to incoming TCP requests and translates them into method calls + /// A Netler Server listens to incoming TCP requests and translates them into method calls. /// + /// + /// + /// var server = Server.Create(config => + /// { + /// config.UsePort(5544); + /// config.UseRoutes(routes => + /// { + /// routes.AddTyped("Add", (int a, int b) => a + b); + /// routes.AddTyped("Ping", () => "pong"); + /// }); + /// }); + /// + /// await server.Start(); + /// + /// public partial class Server { private readonly IConfiguration _configuration; @@ -24,9 +39,24 @@ private Server() } /// - /// Creates new Netler Server instance + /// Creates a new Netler Server instance with the provided configuration. /// - /// Callback for configuring the server instance + /// A callback that configures the server (port, routes, logger, etc.). + /// A configured instance, ready to be started. + /// + /// + /// var server = Server.Create(config => + /// { + /// config.UsePort(5544); + /// config.UseRoutes(routes => + /// { + /// routes.AddTyped("Add", (int a, int b) => a + b); + /// routes.AddTyped("Double", (int x) => x * 2); + /// routes.AddTyped("Ping", () => "pong"); + /// }); + /// }); + /// + /// public static Server Create(Action configure) { var server = new Server(); @@ -35,9 +65,26 @@ public static Server Create(Action configure) } /// - /// Starts the Netler Server + /// Starts the Netler Server and blocks until is called or the + /// is signalled. /// - /// Token to cancel the server + /// + /// An optional token that cancels the server loop. When cancelled the server stops + /// accepting new connections and returns. + /// + /// A that completes when the server stops, yielding this instance. + /// + /// + /// // Fire-and-forget — stop later via server.Stop() + /// var serverTask = server.Start(); + /// + /// // Or cancel via a token + /// using var cts = new CancellationTokenSource(); + /// var serverTask = server.Start(cts.Token); + /// cts.CancelAfter(TimeSpan.FromMinutes(5)); + /// await serverTask; + /// + /// public Task Start(CancellationToken cancellationToken = default) { _cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -45,8 +92,18 @@ public Task Start(CancellationToken cancellationToken = default) } /// - /// Stops the Netler Server + /// Stops the Netler Server. Any in-flight request will be allowed to complete before + /// the server loop exits. /// + /// This instance, enabling a fluent call chain. + /// + /// + /// var serverTask = server.Start(); + /// // ... do work ... + /// server.Stop(); + /// await serverTask; + /// + /// public Server Stop() { _cancellationSource?.Cancel(); diff --git a/src/Netler/Server/Contracts/IConfiguration.cs b/src/Netler/Server/Contracts/IConfiguration.cs index 5d3ec01..61793a4 100644 --- a/src/Netler/Server/Contracts/IConfiguration.cs +++ b/src/Netler/Server/Contracts/IConfiguration.cs @@ -4,60 +4,117 @@ namespace Netler.Contracts { /// - /// Configuration parameters for starting a Netler Server + /// Configuration parameters for starting a Netler Server. + /// Pass an to Server.Create to configure the server. /// public interface IConfiguration { /// - /// Which port the Netler Server should listen to + /// Sets the TCP port the server listens on. /// + /// A valid TCP port number (1–65535). + /// + /// + /// Server.Create(config => + /// { + /// config.UsePort(5544); + /// }); + /// + /// void UsePort(int port); /// - /// By passing a client OS pid the Netler Server will automatically shut down when the client shuts down + /// Monitors the OS process with the given PID and reacts when it exits. + /// The reaction is controlled by ; + /// by default the entire application is shut down. /// + /// The OS process ID of the client process to monitor. + /// + /// + /// Server.Create(config => + /// { + /// config.UsePort(5544); + /// config.UseClientPid(clientProcessId); + /// config.UseClientDisconnectBehaviour(ClientDisconnectBehaviour.DisposeServer); + /// }); + /// + /// void UseClientPid(int pid); /// - /// By passing a client OS pid the Netler Server will automatically react when a client disconnects - /// (default by shutting down the entire application). Use to change the behaviour. + /// Configures what the server does when the monitored client process exits. + /// Only meaningful when is also called. /// + /// The disconnect behaviour to apply. + /// + /// + /// config.UseClientDisconnectBehaviour(ClientDisconnectBehaviour.DisposeServer); + /// + /// void UseClientDisconnectBehaviour(ClientDisconnectBehaviour behaviour); /// - /// Which routes the Netler Server should expose + /// Registers the route table the server exposes to clients. /// - /// A mapping of string routes to methods that are executed when the route is called + /// + /// A callback that receives an builder and registers one or + /// more named route handlers. + /// + /// + /// + /// config.UseRoutes(routes => + /// { + /// // Typed — types are inferred from the lambda + /// routes.AddTyped("Add", (int a, int b) => a + b); + /// routes.AddTyped("Ping", () => "pong"); + /// + /// // Raw — manual parameter decoding + /// routes.Add("Multiply", param => + /// { + /// var a = Convert.ToInt32(param[0]); + /// var b = Convert.ToInt32(param[1]); + /// return a * b; + /// }); + /// }); + /// + /// void UseRoutes(Action routes); /// - /// Configures an for the server to write diagnostic messages to. - /// Defaults to when not set. + /// Attaches an for server diagnostics. + /// When not called, logging is silently discarded via NullLogger. /// + /// The logger instance to write to. + /// + /// + /// ILogger logger = loggerFactory.CreateLogger<MyApp>(); + /// config.UseLogger(logger); + /// + /// void UseLogger(ILogger logger); /// - /// The currently configured client disconnect behaviour + /// Returns the configured , or null if not set. /// ClientDisconnectBehaviour? GetClientDisconnectBehaviour(); /// - /// The currently configured routes + /// Returns the configured instance. /// IRoutes GetRoutes(); /// - /// The currently configured port + /// Returns the configured TCP port. /// int GetPort(); /// - /// The currently configured client pid + /// Returns the configured client process ID, or null if not set. /// int? GetClientPid(); /// - /// The currently configured logger + /// Returns the configured . Defaults to NullLogger. /// ILogger GetLogger(); } diff --git a/src/Netler/Server/Contracts/IRoutes.cs b/src/Netler/Server/Contracts/IRoutes.cs index 555c19e..3cc719d 100644 --- a/src/Netler/Server/Contracts/IRoutes.cs +++ b/src/Netler/Server/Contracts/IRoutes.cs @@ -3,22 +3,55 @@ namespace Netler.Contracts { /// - /// Message routing for a Netler Server + /// The route table for a Netler Server. Routes map string names to handler functions. + /// Use AddTyped () for strongly-typed registration, + /// or with for + /// explicit functional composition. /// public interface IRoutes { /// - /// Adds a new route to the route table + /// Registers a raw route handler. The handler receives the MessagePack-decoded parameter + /// array and returns an (or null for void handlers). /// - /// The exposed name of the route. Example: "CreateNewFoo" - /// A method to execute for calls to the route + /// + /// The name clients use to invoke this handler (e.g. "Add", "CreateUser"). + /// + /// + /// The handler function. Parameters arrive as a raw object[]; cast or use + /// to get typed values. + /// + /// + /// + /// // Preferred — types are inferred from the lambda + /// routes.AddTyped("Add", (int a, int b) => a + b); + /// + /// // Explicit composition with Params.Decode + /// routes.Add("Add", Params.Decode((int a, int b) => a + b)); + /// + /// // Raw — manual decoding + /// routes.Add("Add", param => + /// { + /// var a = Convert.ToInt32(param[0]); + /// var b = Convert.ToInt32(param[1]); + /// return a + b; + /// }); + /// + /// void Add(string route, Func method); /// - /// Invokes the method that is linked to a route + /// Invokes the handler registered under with the supplied parameters. /// - /// The exposed name of the route. Example: "CreateNewFoo" - /// A list of parameters to pass to the method + /// The name of the route to invoke. + /// The parameter array to pass to the handler. + /// The return value of the handler, or null for void handlers. + /// + /// Thrown when no handler is registered under . + /// + /// + /// Thrown when the handler throws an unhandled exception. + /// object Invoke(string route, object[] parameters); } -} \ No newline at end of file +} From 29b4eb127c4b5b6a4e2648578f8128da4f45911a Mon Sep 17 00:00:00 2001 From: Erik Hahne Tensaye Date: Sat, 28 Feb 2026 17:02:57 +0100 Subject: [PATCH 10/10] Review fixes --- src/Netler/Netler.csproj | 4 +- src/Netler/Server.cs | 137 ++++++++++++++------------ src/Netler/StreamExtensions.cs | 5 + src/Netler/TypedConvert.cs | 2 +- tests/IntegrationTests/ServerTests.cs | 8 +- 5 files changed, 85 insertions(+), 71 deletions(-) diff --git a/src/Netler/Netler.csproj b/src/Netler/Netler.csproj index 47bab6f..a54088f 100644 --- a/src/Netler/Netler.csproj +++ b/src/Netler/Netler.csproj @@ -15,8 +15,8 @@ - - + + diff --git a/src/Netler/Server.cs b/src/Netler/Server.cs index 8cc5243..47419ae 100644 --- a/src/Netler/Server.cs +++ b/src/Netler/Server.cs @@ -87,6 +87,7 @@ public static Server Create(Action configure) /// public Task Start(CancellationToken cancellationToken = default) { + _cancellationSource?.Dispose(); _cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); return Task.Run(() => StartServerAsync(_cancellationSource.Token), _cancellationSource.Token); } @@ -106,7 +107,8 @@ public Task Start(CancellationToken cancellationToken = default) /// public Server Stop() { - _cancellationSource?.Cancel(); + try { _cancellationSource?.Cancel(); } + catch (ObjectDisposedException) { } return this; } @@ -119,86 +121,93 @@ private async Task StartServerAsync(CancellationToken ct) var listener = new TcpListener(IPAddress.Loopback, port); listener.Start(); - - LogListening(logger, port); - - if (clientPid != null) - { - StartCheckingIfClientIsAlive( - (int)clientPid, - (ClientDisconnectBehaviour)_configuration.GetClientDisconnectBehaviour(), - ct, - logger); - } - - TcpClient tcpClient; try { + LogListening(logger, port); + + if (clientPid != null) + { + StartCheckingIfClientIsAlive( + (int)clientPid, + (ClientDisconnectBehaviour)_configuration.GetClientDisconnectBehaviour(), + ct, + logger); + } + + TcpClient tcpClient; + try + { #if NET8_0_OR_GREATER - tcpClient = await listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); + tcpClient = await listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); #else - // Optimization: dispose the registration once AcceptTcpClientAsync returns - // so the CT callback cannot fire after the listener is already in use. - using var registration = ct.Register(() => listener.Stop()); - tcpClient = await listener.AcceptTcpClientAsync().ConfigureAwait(false); + // Optimization: dispose the registration once AcceptTcpClientAsync returns + // so the CT callback cannot fire after the listener is already in use. + using var registration = ct.Register(() => listener.Stop()); + tcpClient = await listener.AcceptTcpClientAsync().ConfigureAwait(false); #endif - } - catch (SocketException) when (ct.IsCancellationRequested) - { - LogStoppedBeforeConnect(logger); - return this; - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - LogStoppedBeforeConnect(logger); - return this; - } + } + catch (SocketException) when (ct.IsCancellationRequested) + { + LogStoppedBeforeConnect(logger); + return this; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + LogStoppedBeforeConnect(logger); + return this; + } - LogClientConnected(logger); + LogClientConnected(logger); - using var client = tcpClient; - var stream = client.GetStream(); + using var client = tcpClient; + var stream = client.GetStream(); - while (!ct.IsCancellationRequested) - { - try + while (!ct.IsCancellationRequested) { - var encodedRequest = await stream.ReadWithHeaderAsync(ct).ConfigureAwait(false); - var request = Request.Decode(encodedRequest); + try + { + var encodedRequest = await stream.ReadWithHeaderAsync(ct).ConfigureAwait(false); + var request = Request.Decode(encodedRequest); - LogRequest(logger, request.Route); + LogRequest(logger, request.Route); - try + try + { + var methodResponse = routes.Invoke(request.Route, request.Parameters); + var response = new Response(Response.Code.Ok, methodResponse); + await stream.WriteWithHeaderAsync(response.Encode(), ct).ConfigureAwait(false); + } + catch (RouteMethodCallFailed ex) + { + LogRouteError(logger, ex, request.Route); + var response = new Response(Response.Code.Error, ex.InnerException.Message); + await stream.WriteWithHeaderAsync(response.Encode(), ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) { - var methodResponse = routes.Invoke(request.Route, request.Parameters); - var response = new Response(Response.Code.Ok, methodResponse); - await stream.WriteWithHeaderAsync(response.Encode(), ct).ConfigureAwait(false); + break; } - catch (RouteMethodCallFailed ex) + catch (System.IO.EndOfStreamException) { - LogRouteError(logger, ex, request.Route); - var response = new Response(Response.Code.Error, ex.InnerException.Message); - await stream.WriteWithHeaderAsync(response.Encode(), ct).ConfigureAwait(false); + // Client closed the connection + break; + } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + LogUnexpectedError(logger, ex); + break; } } - catch (OperationCanceledException) - { - break; - } - catch (System.IO.EndOfStreamException) - { - // Client closed the connection - break; - } - catch (Exception ex) when (!ct.IsCancellationRequested) - { - LogUnexpectedError(logger, ex); - break; - } - } - LogServerStopped(logger); - return this; + LogServerStopped(logger); + return this; + } + finally + { + listener.Stop(); + _cancellationSource?.Dispose(); + } } private void StartCheckingIfClientIsAlive(int clientPid, ClientDisconnectBehaviour behaviour, CancellationToken ct, ILogger logger) diff --git a/src/Netler/StreamExtensions.cs b/src/Netler/StreamExtensions.cs index 8956107..012d4c4 100644 --- a/src/Netler/StreamExtensions.cs +++ b/src/Netler/StreamExtensions.cs @@ -9,6 +9,7 @@ namespace Netler internal static class StreamExtensions { const int HeaderSize = 4; + const int MaxFrameSize = 256 * 1024 * 1024; // 256 MB — guard against malformed or malicious frames internal static byte[] ReadWithHeader(this NetworkStream stream) { @@ -16,6 +17,8 @@ internal static byte[] ReadWithHeader(this NetworkStream stream) ReadExactly(stream, header, HeaderSize); Array.Reverse(header); var contentLength = BitConverter.ToInt32(header, 0); + if (contentLength < 0 || contentLength > MaxFrameSize) + throw new InvalidDataException($"Invalid frame length: {contentLength}"); var content = new byte[contentLength]; ReadExactly(stream, content, contentLength); return content; @@ -37,6 +40,8 @@ internal static async Task ReadWithHeaderAsync(this NetworkStream stream await ReadExactlyAsync(stream, header, HeaderSize, cancellationToken); Array.Reverse(header); var contentLength = BitConverter.ToInt32(header, 0); + if (contentLength < 0 || contentLength > MaxFrameSize) + throw new InvalidDataException($"Invalid frame length: {contentLength}"); var content = new byte[contentLength]; await ReadExactlyAsync(stream, content, contentLength, cancellationToken); return content; diff --git a/src/Netler/TypedConvert.cs b/src/Netler/TypedConvert.cs index aafe3a5..2c71053 100644 --- a/src/Netler/TypedConvert.cs +++ b/src/Netler/TypedConvert.cs @@ -118,7 +118,7 @@ public void Dispose() { if (_buffer != null) { - ArrayPool.Shared.Return(_buffer); + ArrayPool.Shared.Return(_buffer, clearArray: true); _buffer = null; } } diff --git a/tests/IntegrationTests/ServerTests.cs b/tests/IntegrationTests/ServerTests.cs index 02c4026..f58682e 100644 --- a/tests/IntegrationTests/ServerTests.cs +++ b/tests/IntegrationTests/ServerTests.cs @@ -123,8 +123,8 @@ public async Task ClientIsReusable() }); - var firstExected = 5; - var secondExected = 37; + var firstExpected = 5; + var secondExpected = 37; int? firstActual = null; int? secondActual = null; @@ -141,8 +141,8 @@ public async Task ClientIsReusable() await Task.WhenAll(serverTask, clientTask); - Assert.Equal(firstExected, firstActual); - Assert.Equal(secondExected, secondActual); + Assert.Equal(firstExpected, firstActual); + Assert.Equal(secondExpected, secondActual); } [Fact]