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..c94ad35 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# AGENTS.md + +Guidelines for AI agents (Copilot, Claude, etc.) contributing to Netler.NET. + +--- + +## Project Structure & Module Organization + +``` +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 + 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 — Hand-maintained API documentation +``` + +--- + +## 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]` 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 diff --git a/DOCS.md b/DOCS.md deleted file mode 100644 index e0c0ddb..0000000 --- a/DOCS.md +++ /dev/null @@ -1,847 +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[])') -- [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)') - - [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()](#M-Netler-Server-Start 'Netler.Server.Start') - - [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 | - - -## 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. - - -### 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 | - - -## 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() `method` - -##### Summary - -Starts a process running the Netler Server - -##### Parameters - -This method has no parameters. - - -### 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/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/Client.cs b/src/Netler/Client.cs index dc3936a..80560ad 100644 --- a/src/Netler/Client.cs +++ b/src/Netler/Client.cs @@ -1,22 +1,37 @@ -using Netler.Exceptions; +using Netler.Exceptions; using System; using System.Net.Sockets; using System.Threading; +using System.Threading.Tasks; 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); @@ -24,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); @@ -35,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); @@ -67,8 +96,74 @@ public object Invoke(string route, object[] parameters) } /// - /// + /// Asynchronously invokes a route on the Netler server and returns the raw result. /// + /// 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); + 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; + } + + /// + /// Asynchronously invokes a route on the Netler server and deserialises the result to + /// . + /// + /// + /// 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/Netler.csproj b/src/Netler/Netler.csproj index 4c0af56..a54088f 100644 --- a/src/Netler/Netler.csproj +++ b/src/Netler/Netler.csproj @@ -1,36 +1,22 @@ - + - netstandard2.0 + netstandard2.0;net8.0 + latest 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/src/Netler/Params.cs b/src/Netler/Params.cs new file mode 100644 index 0000000..5450dc6 --- /dev/null +++ b/src/Netler/Params.cs @@ -0,0 +1,133 @@ +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])); + + /// 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) ─────────────────────── + + /// 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; }; + + /// 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.cs b/src/Netler/Server.cs index 589807c..47419ae 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; @@ -10,13 +11,27 @@ 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. /// - public class Server + /// + /// + /// 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; private CancellationTokenSource _cancellationSource; - private CancellationToken _cancellationToken; private Server() { @@ -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,88 +65,164 @@ public static Server Create(Action configure) } /// - /// Starts a process running the Netler Server + /// Starts the Netler Server and blocks until is called or the + /// is signalled. /// - public Task Start() + /// + /// 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 = new CancellationTokenSource(); - _cancellationToken = _cancellationSource.Token; - return Task.Run(StartServer, _cancellationToken); + _cancellationSource?.Dispose(); + _cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + return Task.Run(() => StartServerAsync(_cancellationSource.Token), _cancellationSource.Token); } /// - /// 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(); + try { _cancellationSource?.Cancel(); } + catch (ObjectDisposedException) { } 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); - - if (clientPid != null) + var listener = new TcpListener(IPAddress.Loopback, port); + listener.Start(); + try { - StartCheckingIfClientIsAlive( - (int)clientPid, - (ClientDisconnectBehaviour)_configuration.GetClientDisconnectBehaviour()); - } + LogListening(logger, port); - listener.Start(); - var client = listener.AcceptTcpClient(); - var stream = client.GetStream(); + if (clientPid != null) + { + StartCheckingIfClientIsAlive( + (int)clientPid, + (ClientDisconnectBehaviour)_configuration.GetClientDisconnectBehaviour(), + ct, + logger); + } - while (!_cancellationSource.IsCancellationRequested) - { - if (!stream.DataAvailable) + TcpClient tcpClient; + try { - Tick(1); +#if NET8_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 } - else + catch (SocketException) when (ct.IsCancellationRequested) { + LogStoppedBeforeConnect(logger); + return this; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + LogStoppedBeforeConnect(logger); + return this; + } - var encodedRequest = stream.ReadWithHeader(); - var request = Request.Decode(encodedRequest); + LogClientConnected(logger); + using var client = tcpClient; + var stream = client.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).ConfigureAwait(false); + var request = Request.Decode(encodedRequest); + + LogRequest(logger, 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).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) + { + break; } - catch (RouteMethodCallFailed ex) + catch (System.IO.EndOfStreamException) { - var response = new Response(Response.Code.Error, ex.InnerException.Message); - stream.WriteWithHeader(response.Encode()); + // Client closed the connection + break; + } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + LogUnexpectedError(logger, ex); + break; } } - } - listener.Stop(); - return this; + LogServerStopped(logger); + return this; + } + finally + { + listener.Stop(); + _cancellationSource?.Dispose(); + } } - 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).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } } - if (_cancellationSource.IsCancellationRequested) - { - return; - } + if (ct.IsCancellationRequested) return; + + LogClientDisconnected(logger, clientPid, behaviour); switch (behaviour) { @@ -130,7 +236,8 @@ private void StartCheckingIfClientIsAlive(int clientPid, ClientDisconnectBehavio case ClientDisconnectBehaviour.KeepAlive: break; } - }); + }, ct); + } private bool ClientIsAlive(int clientPid) { @@ -145,5 +252,32 @@ private bool ClientIsAlive(int clientPid) } } + // ── [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); } } 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..61793a4 100644 --- a/src/Netler/Server/Contracts/IConfiguration.cs +++ b/src/Netler/Server/Contracts/IConfiguration.cs @@ -1,53 +1,121 @@ +using Microsoft.Extensions.Logging; using System; 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); /// - /// The currently configured client disconnect behaviour + /// 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); + + /// + /// 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(); + /// + /// Returns the configured . Defaults to NullLogger. + /// + ILogger GetLogger(); } -} \ No newline at end of file +} 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 +} diff --git a/src/Netler/Server/TypedRouteExtensions.cs b/src/Netler/Server/TypedRouteExtensions.cs new file mode 100644 index 0000000..88089b8 --- /dev/null +++ b/src/Netler/Server/TypedRouteExtensions.cs @@ -0,0 +1,105 @@ +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)); + + /// 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. + 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)); + + /// 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)); + } +} diff --git a/src/Netler/StreamExtensions.cs b/src/Netler/StreamExtensions.cs index ec07bc3..012d4c4 100644 --- a/src/Netler/StreamExtensions.cs +++ b/src/Netler/StreamExtensions.cs @@ -1,20 +1,26 @@ -using System; +using System; +using System.IO; using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; 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) { var header = new byte[HeaderSize]; - stream.Read(header, 0, HeaderSize); + 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]; - stream.Read(content, 0, contentLength); + ReadExactly(stream, content, contentLength); return content; } @@ -27,5 +33,50 @@ 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); + 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; + } + + 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/src/Netler/TypedConvert.cs b/src/Netler/TypedConvert.cs new file mode 100644 index 0000000..2c71053 --- /dev/null +++ b/src/Netler/TypedConvert.cs @@ -0,0 +1,127 @@ +using MessagePack; +using System; +using System.Buffers; + +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("UnitTests")] + +namespace Netler +{ + internal static class TypedConvert + { + // 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 + { + 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); + + // 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; + + internal PooledBufferWriter() + { + _buffer = ArrayPool.Shared.Rent(InitialSize); + _written = 0; + } + + 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, clearArray: true); + _buffer = null; + } + } + } + } +} 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/IntegrationTests/ServerTests.cs b/tests/IntegrationTests/ServerTests.cs index ee67490..f58682e 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; @@ -15,7 +17,7 @@ namespace IntegrationTests public class ServerTests { [Fact] - public void ClientServerCommunication() + public async Task ClientServerCommunication() { var port = FreeTcpPort(); @@ -39,22 +41,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 +88,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(); @@ -121,30 +123,30 @@ public void ClientIsReusable() }); - var firstExected = 5; - var secondExected = 37; + var firstExpected = 5; + var secondExpected = 37; int? firstActual = null; 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); + Assert.Equal(firstExpected, firstActual); + Assert.Equal(secondExpected, secondActual); } [Fact] - public void ClientCatchesServerExceptions() + public async Task ClientCatchesServerExceptions() { var port = FreeTcpPort(); @@ -163,21 +165,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,16 +193,137 @@ 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); } + [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; } + } + } +} 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$"] +}