From 9d4e802c7ee9277174c13ffa29bbbaea8f136844 Mon Sep 17 00:00:00 2001 From: Austin Harris Date: Sun, 27 Sep 2026 23:28:50 -0600 Subject: [PATCH] AUS-1016: document the embedded-host patterns and add samples/EmbeddedHost The API usability review (docs/reviews/2026-09-28_api-usability-review.md) found that every host outside ASP.NET Core rediscovers the same things: a guest process needs a session of its own, the raw-connection reply stream carries no framing, JsonFramer returns -1 for two different conditions, application error codes belong outside the reserved range, the cancellation token may sit between JSON parameters, and there is no dispatch context for a thread that owns the state. samples/EmbeddedHost is three hosts over one service, each about a page: HttpListener (body limit, deadline, 204 for notifications), any Stream with newline-delimited documents and a separate write loop (named pipe in the sample), and a SynchronizationContext that runs every document on one thread. Its check mode runs all three against a client. The project is in the solution and the sample README is a page on the documentation site. README: sessions guidance leads with a named session for guests; the hosting section gains the embedded subsection and the framer's -1 semantics; the raw connection sections state that replies carry no newline or Content-Length; the -32000 examples become application codes with the reason; unknown named members, the token's position, the public read path for bound methods (MetaData.Services), the adapter pattern for the context, and the AOT constraint are documented where a reader meets them. --- .github/workflows/pages.yml | 1 + AustinHarris.JsonRpc.AspNetCore/README.md | 5 +- AustinHarris.JsonRpc.sln | 23 ++++ CHANGELOG.md | 1 + README.md | 40 ++++-- samples/EmbeddedHost/EmbeddedHost.csproj | 20 +++ samples/EmbeddedHost/HttpListenerHost.cs | 81 +++++++++++ samples/EmbeddedHost/Operations.cs | 77 +++++++++++ samples/EmbeddedHost/Program.cs | 156 ++++++++++++++++++++++ samples/EmbeddedHost/README.md | 27 ++++ samples/EmbeddedHost/StreamHost.cs | 147 ++++++++++++++++++++ samples/EmbeddedHost/UiThread.cs | 57 ++++++++ site/README.md | 1 + site/build.py | 2 + 14 files changed, 626 insertions(+), 12 deletions(-) create mode 100644 samples/EmbeddedHost/EmbeddedHost.csproj create mode 100644 samples/EmbeddedHost/HttpListenerHost.cs create mode 100644 samples/EmbeddedHost/Operations.cs create mode 100644 samples/EmbeddedHost/Program.cs create mode 100644 samples/EmbeddedHost/README.md create mode 100644 samples/EmbeddedHost/StreamHost.cs create mode 100644 samples/EmbeddedHost/UiThread.cs diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index ce34155..770e464 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -11,6 +11,7 @@ on: - "AustinHarris.JsonRpc.Newtonsoft/README.md" - "AustinHarris.JsonRpc.SystemTextJson/README.md" - "samples/WasmHost/README.md" + - "samples/EmbeddedHost/README.md" - "benchmarks/Micro/README.md" - "benchmarks/charts/**" - ".github/workflows/pages.yml" diff --git a/AustinHarris.JsonRpc.AspNetCore/README.md b/AustinHarris.JsonRpc.AspNetCore/README.md index 92c86fb..3eb546a 100644 --- a/AustinHarris.JsonRpc.AspNetCore/README.md +++ b/AustinHarris.JsonRpc.AspNetCore/README.md @@ -138,7 +138,10 @@ builder.WebHost.ConfigureKestrel(k => Clients write JSON documents back to back (whitespace or newlines between them are fine) and read the responses in the same order, also back to back with no newline or `Content-Length` prefix, so the client must parse one -complete JSON value at a time. Notifications produce nothing. The `ConnectionContext` is the RPC context. +complete JSON value at a time. A client that expects newline-delimited or `Content-Length`-framed replies (an LSP +or MCP stdio client) is not served by this handler; a host that adds the framing over any `Stream` is in +[samples/EmbeddedHost](../samples/EmbeddedHost). Notifications produce nothing. The `ConnectionContext` is the RPC +context. The framer accepts strict JSON only, even with the Json.NET serializer or a lenient `JsmnSerializer` selected. A document larger than `MaxRequestBytes` aborts the connection. Documents on one connection are processed one at diff --git a/AustinHarris.JsonRpc.sln b/AustinHarris.JsonRpc.sln index 3433a7c..0b851ba 100644 --- a/AustinHarris.JsonRpc.sln +++ b/AustinHarris.JsonRpc.sln @@ -23,6 +23,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WasmHost", "samples\WasmHost\WasmHost.csproj", "{293EB57E-ED38-4127-879C-34FD73820127}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmbeddedHost", "samples\EmbeddedHost\EmbeddedHost.csproj", "{7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -175,12 +177,33 @@ Global {293EB57E-ED38-4127-879C-34FD73820127}.Release|x86.Build.0 = Release|Any CPU {293EB57E-ED38-4127-879C-34FD73820127}.Release|x64.ActiveCfg = Release|Any CPU {293EB57E-ED38-4127-879C-34FD73820127}.Release|x64.Build.0 = Release|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Debug|ARM.ActiveCfg = Debug|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Debug|ARM.Build.0 = Debug|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Debug|x86.ActiveCfg = Debug|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Debug|x86.Build.0 = Debug|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Debug|x64.ActiveCfg = Debug|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Debug|x64.Build.0 = Debug|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Release|Any CPU.Build.0 = Release|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Release|ARM.ActiveCfg = Release|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Release|ARM.Build.0 = Release|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Release|x86.ActiveCfg = Release|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Release|x86.Build.0 = Release|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Release|x64.ActiveCfg = Release|Any CPU + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {293EB57E-ED38-4127-879C-34FD73820127} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {7A4C1E52-9B3D-4F61-8E2A-5C0D7B9F3A11} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} EndGlobalSection GlobalSection(TestCaseManagementSettings) = postSolution CategoryFile = AustinHarris.JsonRpc.vsmdi diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ae069..11acd2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ behaviour: a breaking change to either means a new major version. - `AddJsonRpcService(ServiceLifetime, sessionId)` and the matching `AddJsonRpcServicesFromAssembly` overload: scoped and transient services are resolved per call from `HttpContext.RequestServices`, or from a scope the raw connection handler opens per document and publishes as `IServiceProvidersFeature`; a batch shares one scope. `JsonRpcOptions.ServiceProviderSelector` locates the provider from a custom context. A lifetime that conflicts with the container's registration, a non-singleton `JsonRpcService` subclass, and a `ContextFactory` without a selector are refused at registration or startup. - `protected JsonRpcService(bool autoBind)`: a subclass constructed with `base(false)` binds itself nowhere, for services that a host or an explicit `BindService` call binds. - `SECURITY.md` (private vulnerability reporting) and this changelog. +- [samples/EmbeddedHost](samples/EmbeddedHost): the core hosted without ASP.NET Core, through `HttpListener`, over a named pipe with newline-delimited documents, and on a thread that owns the state; its `check` mode runs all three against a client. - `TestServer_Console --scale` is the release gate for the `ProcessAsync` path. It measures the inline rows at 1, 2 and N workers in three paired runs, takes the medians and fails when N/1 is below the threshold. `--kestrel [seconds] async` runs the host with `EnableAsyncMethods = true`. The README adds `--async` rows for `ProcessAsync` at 1 and 16 workers. The 1.x string overloads' thread-pool benchmark is now the `t` menu entry and no longer the default. - `--scale` prints per-serializer and yielding-row diagnostics after the gate; the Kestrel `EnableAsyncMethods = true` row with methods that suspend once is a release-required regression row: re-measured before each release against the previous release's figure, with no absolute floor. diff --git a/README.md b/README.md index ce156ec..f68a0fc 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ dotnet add package AustinHarris.JsonRpc --prerelease 2.0 is published as `2.0.0-preview.3`; without `--prerelease`, NuGet resolves to the last 1.x release. The four assemblies are strong-named with one key (public key token `e6819c02cf4aec44`) that is checked into the repository and does not change between releases, so a signed caller can reference them and .NET Framework loads them. +The packages are not annotated for trimming or Native AOT: a project that sets `IsAotCompatible` or `PublishTrimmed` gets trim warnings from the reflection binder, and a trimmed application can lose the methods it binds. Hosts with that constraint wait for the source generator planned for 2.8; see [Versioning and support](#versioning-and-support). + Add `AustinHarris.JsonRpc.Newtonsoft` or `AustinHarris.JsonRpc.SystemTextJson` if you want that serializer, and `AustinHarris.JsonRpc.AspNetCore` to host in Kestrel. ## Getting started @@ -144,9 +146,9 @@ public class CalculatorService : JsonRpcService } ``` -Methods can be `private`; parameters may be positional (`"params":[1,2]`) or named (`"params":{"l":1,"r":2}`). Optional parameters with default values are honoured, and a parameter's JSON name can be overridden with `[JsonRpcParam("name")]`. +Methods can be `private`; parameters may be positional (`"params":[1,2]`) or named (`"params":{"l":1,"r":2}`). Optional parameters with default values are honoured, and a parameter's JSON name can be overridden with `[JsonRpcParam("name")]`. A named member that matches no parameter is refused with `-32602` naming it, never dropped, so a misspelled name cannot silently fall back to a default value. -Every method lives in a *session*, a named set of methods. Both examples register methods in the default session (`Handler.DefaultSessionId()`), which is all most applications need. Lambdas and classes can be mixed in one session, but method names must be unique: `BindMethod` throws for a name that is already registered, and a class bound afterwards replaces an earlier registration of the same name. Both examples register `add`, so keep one of them. Overloads that take a `sessionId` let one process serve separate method sets; see [Sessions and context](#sessions-and-context). +Every method lives in a *session*, a named set of methods with its own handlers and serializer. Both examples register methods in the default session (`Handler.DefaultSessionId()`), which is fine for an application that owns its process. Code that is a guest in someone else's process, such as a plugin, an add-in or a library, should name a session of its own (`ServiceBinder.BindService("my-plugin", this)` and the matching `sessionId` on the processor calls): the default session is one process-wide registry shared with every other component that uses the library, so a guest that binds there can collide with, replace or be replaced by methods it has never heard of, and a handler it sets on `Config` changes them all. Lambdas and classes can be mixed in one session, but method names must be unique: `BindMethod` throws for a name that is already registered, and a class bound afterwards replaces an earlier registration of the same name. Both examples register `add`, so keep one of them. See [Sessions and context](#sessions-and-context). The next step drives `CalculatorService` in process, without a transport. @@ -243,13 +245,17 @@ An explicit `[JsonRpcMethod("alias")]` on an interface method is used as written `BindInterface` returns an `RpcBinding`. Disposing it unbinds exactly this tree (not a later registration under the same names), is safe to call twice, and does not dispose your objects. Calls already dispatched finish on the implementation they started with. +### Reading what is bound + +Tooling that lists or inspects a session reads `Handler.GetSessionHandler(sessionId).MetaData.Services`, an `IDictionary` keyed by wire name. Each entry's `Method` is the compiled `RpcMethod` (`Name`, `Parameters` with their types and defaults, `ReturnType`) and `dele` is the delegate behind it. The collection is a live view: read it, do not modify it, and register and unregister through `ServiceBinder` and `Handler.DestroySession`. + ## Hosting The core is transport-agnostic. Pick whichever of these fits, or build your own on the byte entry point. ### In-process (strings or bytes) -Call the processor yourself, as in [Getting started](#getting-started). The byte overloads take what a `PipeReader` gives you (`ReadOnlySequence`) and write to any `IBufferWriter`: a `PipeWriter`, a socket buffer or `HttpResponse.BodyWriter`. Nothing is written for a notification, so check `output.WrittenCount` before sending. If your transport carries several documents per connection, `JsonFramer.TryReadDocument` cuts complete documents out of the byte stream without parsing them. +Call the processor yourself, as in [Getting started](#getting-started). The byte overloads take what a `PipeReader` gives you (`ReadOnlySequence`) and write to any `IBufferWriter`: a `PipeWriter`, a socket buffer or `HttpResponse.BodyWriter`. Nothing is written for a notification, so check `output.WrittenCount` before sending. If your transport carries several documents per connection, `JsonFramer.TryReadDocument` cuts complete documents out of the byte stream without parsing them. Its span form, `JsonFramer.FindDocumentEnd`, returns the length of the first complete document and `-1` both when the document is still incomplete and when the buffer does not start with `{` or `[` after whitespace, so a host tells the two apart itself (the first non-whitespace byte) or a line of garbage stalls the connection. Nothing in the core bounds a host's receive buffer: cap it at the document limit you would accept and fail the connection at the cap, as the Kestrel handler does with `MaxRequestBytes`. A host that owns its transport also owns the deadline (the core has none; see [Deadlines](#asynchronous-methods-and-cancellation)). Link a `CancellationTokenSource` to the connection's lifetime token, arm it with `CancelAfter` and pass its token to `ProcessAsync` and to the write of the reply. When the budget expires, abort the transport at once, but still await the call: `ProcessAsync` completes only after the running method has terminated, a cancelled call commits no response bytes, and until the await returns the request memory and the output writer belong to the call, so neither goes back to a pool or is reused before then. @@ -284,6 +290,16 @@ static async Task ServeDocumentAsync(string sessionId, byte[] rented, int length } ``` +### Embedded: HTTP without ASP.NET Core, a pipe without Kestrel, a thread that owns the state + +A plugin, an add-in, a desktop or editor process or a daemon cannot always bring the ASP.NET Core shared framework in, and often has a thread that must run the methods. [samples/EmbeddedHost](samples/EmbeddedHost) is three hosts over one service, each about a page, with a `check` mode that runs them against a client: + +- **`HttpListenerHost.cs`**: HTTP through `System.Net.HttpListener`, which is in the base library. What `MapJsonRpc` does for you, this host does itself: a body limit, a deadline (HttpListener has no client-abort token, so the host arms its own and answers `504` when it fires), `204` for a notification, `405` for anything but `POST`. +- **`StreamHost.cs`**: any `Stream` (the sample uses a named pipe) with newline-delimited documents in both directions, which is what MCP's stdio transport and most line-oriented clients expect. The host frames with `JsonFramer`, bounds its buffer, runs documents one at a time in order, and keeps a *separate* write loop fed by a queue: one loop that reads, processes and writes in turn deadlocks against a client that pipelines requests, because both ends end up waiting for the other to read. Server-side events become outbound notifications on the same queue, so they never interleave with a reply mid-write. +- **`UiThread.cs`**: a `SynchronizationContext` over one thread. Every document, and every continuation after an `await` inside a method, runs on that thread, so methods touch UI or editor state without locks. The core has no dispatch context of its own (that arrives with duplex in 2.6), so the host marshals whole documents, not individual calls. + +The service passes `HttpListenerContext` or the connection's `Stream` as the RPC context, so a method can read `Handler.RpcContext()` before its first `await` and reach the caller; the same adapter shape works for any transport object. + ### Kestrel HTTP endpoint ```csharp @@ -313,7 +329,7 @@ builder.WebHost.ConfigureKestrel(k => }); ``` -Clients write JSON documents back to back (whitespace or newlines between them are fine) and read the responses in the same order, also back to back with no separator; notifications produce nothing. The framer accepts strict JSON only, so single-quoted strings and other lenient syntax are refused on a raw connection even with the Json.NET serializer. +Clients write JSON documents back to back (whitespace or newlines between them are fine) and read the responses in the same order, also back to back: no newline, no `Content-Length` header, so the client must cut one complete JSON value at a time out of the stream. A client that expects newline-delimited or `Content-Length`-framed replies (an LSP or MCP stdio client) needs a host that adds that framing; [the embedded stream host](#embedded-http-without-aspnet-core-a-pipe-without-kestrel-a-thread-that-owns-the-state) shows the newline form. Notifications produce nothing. The framer accepts strict JSON only, so single-quoted strings and other lenient syntax are refused on a raw connection even with the Json.NET serializer. A raw connection has no authentication, authorisation or rate limiting; those are HTTP middleware and do not run here. Listen on loopback or a Unix socket, or put something in front that authenticates. A document larger than `MaxRequestBytes` (4 MB) aborts the connection. The core applies its own `JsonRpcLimits` to the document the host hands over, so the transport limit is met first and the core limit second. @@ -335,7 +351,7 @@ By default (`Config.IncludeExceptionDetails = false`), an unhandled exception th ```csharp Config.SetErrorHandler((request, error) => - error.data is Exception ex ? new JsonRpcException(-32000, "Server error", Log(ex)) : error); + error.data is Exception ex ? new JsonRpcException(1000, "Server error", Log(ex)) : error); ``` Set `Config.IncludeExceptionDetails = true` only for trusted development clients: `data` then carries the full `ExceptionInfo` (`ClassName`, `Message`, `Source`, `StackTraceString`, `HResult` and the `InnerException` chain). @@ -346,11 +362,11 @@ The exception a handler receives is the one the method threw, with its own `Inne ### Handlers -Throw `JsonRpcException(code, message, data)` to return an error of your own. To reshape errors, or to inspect requests on the way in and out, register handlers. Each handler belongs to one session: +Throw `JsonRpcException(code, message, data)` to return an error of your own. Pick the code outside `-32768..-32000`: the specification reserves that range, `-32700..-32600` for the errors in the table below and `-32099..-32000` for the server implementation, and a client that sees `-32000` cannot tell your error from the server's. Positive codes, or negative ones above `-32000`, are yours; the library raises none of them. To reshape errors, or to inspect requests on the way in and out, register handlers. Each handler belongs to one session: ```csharp // Default session -Config.SetErrorHandler((request, exception) => new JsonRpcException(-32000, "Server error", exception.data)); +Config.SetErrorHandler((request, exception) => new JsonRpcException(1000, "Server error", exception.data)); Config.SetParseErrorHandler((rawJson, exception) => exception); Config.SetPreProcessHandler((request, context) => null); // return a JsonRpcException to reject; may replace Method/Params/Id Config.SetPostProcessHandler((request, response, context) => null); // return a JsonRpcException to replace the result @@ -401,7 +417,7 @@ string response = await JsonRpcProcessor.ProcessAsync(sessionId, requestJson, **Order.** A batch runs one request at a time, in order. Notifications are awaited like any other request. -**Cancellation.** To receive the processor's token, a method declares a `CancellationToken` parameter marked `[JsonRpcCancellation]`. That parameter never binds from JSON and is left out of the SMD. A `CancellationToken` parameter without the attribute is rejected at registration. A synchronous method called through `ProcessAsync` receives the token too; through `Process` and `ProcessSync` it receives the default token. When the token fires: +**Cancellation.** To receive the processor's token, a method declares a `CancellationToken` parameter marked `[JsonRpcCancellation]`. That parameter never binds from JSON and is left out of the SMD, so it may sit anywhere in the signature: positional and named parameters are matched as if it were not there. Since C# wants optional parameters last, `(int id, [JsonRpcCancellation] CancellationToken token, int limit = 10)` is the natural shape. A `CancellationToken` parameter without the attribute is rejected at registration. A synchronous method called through `ProcessAsync` receives the token too; through `Process` and `ProcessSync` it receives the default token. When the token fires: - the processor checks it before each invocation, between batch elements, and before writing the response; - a method already running is waited for, not abandoned, so a method that ignores the token delays cancellation; @@ -420,7 +436,7 @@ public async Task Lookup(int id) var http = (HttpContext)Handler.RpcContext(); // before the first await JsonRpcRequestId requestId = Handler.RpcRequestId(); // an owned copy, safe to keep var item = await repository.FindAsync(id); - if (item == null) throw new JsonRpcException(-32000, "Not found", null); + if (item == null) throw new JsonRpcException(1001, "Not found", null); return item; } ``` @@ -432,7 +448,7 @@ If a method needs the accessors after awaiting, opt it into `RpcContextFlow.Flow public async Task Lookup(int id) { var item = await repository.FindAsync(id); - if (item == null) Handler.RpcSetException(new JsonRpcException(-32000, "Not found", null)); + if (item == null) Handler.RpcSetException(new JsonRpcException(1001, "Not found", null)); return item; } @@ -498,6 +514,8 @@ await JsonRpcProcessor.Process(request, context: httpContext); private string WhoAmI() => ((HttpContext)Handler.RpcContext()).User.Identity.Name; ``` +A host of your own passes whatever names the caller: the request object, the connection, a client record. Read it at the top of the method, before the first `await` (the default context flow does not cross one; see [Ambient context after an await](#asynchronous-methods-and-cancellation)), and keep what you need in a local. Methods that must not depend on the transport take an interface the host implements and cast to that, so the same service runs under Kestrel, under `HttpListener` and in the tests. + ### The request id The request's `id` is available the same way, read on demand from the request bytes, so a method that never asks pays nothing: @@ -823,7 +841,7 @@ Most 1.x services run unchanged. [Upgrading from 1.x](docs/upgrading.md) lists t - **Releases.** The four packages are built from one repository, carry one version number and are released together; use matching versions. There is no release cadence. - **Previews.** 2.0 ships as `2.0.0-preview.N` first. A preview is complete and tested, but the public API may still change between previews; the stable 2.0.0 follows once the API has settled. - **Tested** means the `net8.0` and `net10.0` test runs on Windows and Linux listed under [Requirements](#requirements). Other runtimes can load the `netstandard` assets and are not tested. -- **Trimming and Native AOT** are unsupported until the library is annotated and that is validated in CI. +- **Trimming and Native AOT** are unsupported until the library is annotated and that is validated in CI, planned for 2.8 with the source generator. A host that publishes trimmed or with `PublishAot` today sees trim warnings from the reflection binder and can lose bound methods; that is the one constraint that keeps a host off 2.0. - **1.x** receives no further releases. - **Changes** are recorded per version in [CHANGELOG.md](CHANGELOG.md); the NuGet release notes link there. - **Vulnerabilities** are reported privately, see [SECURITY.md](SECURITY.md). Questions and bugs go to [GitHub issues](https://github.com/Astn/JSON-RPC.NET/issues). diff --git a/samples/EmbeddedHost/EmbeddedHost.csproj b/samples/EmbeddedHost/EmbeddedHost.csproj new file mode 100644 index 0000000..b4a1d0e --- /dev/null +++ b/samples/EmbeddedHost/EmbeddedHost.csproj @@ -0,0 +1,20 @@ + + + + + Exe + net10.0 + disable + enable + false + EmbeddedHost + + + + + + + diff --git a/samples/EmbeddedHost/HttpListenerHost.cs b/samples/EmbeddedHost/HttpListenerHost.cs new file mode 100644 index 0000000..23d0329 --- /dev/null +++ b/samples/EmbeddedHost/HttpListenerHost.cs @@ -0,0 +1,81 @@ +using System.Net; +using AustinHarris.JsonRpc.Serialization; +using AustinHarris.JsonRpc; + +namespace EmbeddedHost; + +/// +/// HTTP without ASP.NET Core: a plugin cannot bring the Microsoft.AspNetCore.App shared framework into a host that +/// is not an ASP.NET Core application, and System.Net.HttpListener is in the base library. What the Kestrel +/// endpoint does for you and this host has to do itself: a body limit, a deadline (HttpListener exposes no +/// "client went away" token), 204 for a notification, and the status codes. +/// +public static class HttpListenerHost +{ + private const long MaxRequestBytes = 4 * 1024 * 1024; + private static readonly TimeSpan Deadline = TimeSpan.FromSeconds(30); + + /// An HttpListener prefix such as http://127.0.0.1:5078/rpc/ (the trailing slash matters). + public static async Task RunAsync(string session, string prefix, CancellationToken stop) + { + using var listener = new HttpListener(); + listener.Prefixes.Add(prefix); + listener.Start(); + using var stopping = stop.Register(listener.Stop); + while (!stop.IsCancellationRequested) + { + HttpListenerContext context; + try { context = await listener.GetContextAsync(); } + catch (HttpListenerException) when (stop.IsCancellationRequested) { break; } + catch (ObjectDisposedException) { break; } + _ = ServeAsync(session, context); + } + } + + private static async Task ServeAsync(string session, HttpListenerContext context) + { + var response = context.Response; + try + { + if (context.Request.HttpMethod != "POST") { response.StatusCode = 405; return; } + if (context.Request.ContentLength64 > MaxRequestBytes) { response.StatusCode = 413; return; } + + // Read the body under the limit: ContentLength64 is -1 for a chunked body, so count while copying. + using var body = new MemoryStream(); + var chunk = new byte[16 * 1024]; + int read; + while ((read = await context.Request.InputStream.ReadAsync(chunk)) > 0) + { + if (body.Length + read > MaxRequestBytes) { response.StatusCode = 413; return; } + body.Write(chunk, 0, read); + } + + // The only deadline is the one this host arms; the core has none and HttpListener offers no abort token. + using var deadline = new CancellationTokenSource(Deadline); + using var output = new PooledByteBufferWriter(); + try + { + await JsonRpcProcessor.ProcessAsync(session, new ReadOnlyMemory(body.GetBuffer(), 0, (int)body.Length), + output, context: context, cancellationToken: deadline.Token); + } + catch (OperationCanceledException) + { + response.StatusCode = 504; // the call has terminated and wrote nothing + return; + } + + if (output.WrittenCount == 0) { response.StatusCode = 204; return; } // a notification + response.ContentType = "application/json"; + response.ContentLength64 = output.WrittenCount; + await response.OutputStream.WriteAsync(output.WrittenMemory); + } + catch (Exception) + { + try { response.StatusCode = 500; } catch (Exception) { } + } + finally + { + response.Close(); + } + } +} diff --git a/samples/EmbeddedHost/Operations.cs b/samples/EmbeddedHost/Operations.cs new file mode 100644 index 0000000..fdec4f1 --- /dev/null +++ b/samples/EmbeddedHost/Operations.cs @@ -0,0 +1,77 @@ +using AustinHarris.JsonRpc; + +namespace EmbeddedHost; + +/// +/// The methods every host in this sample exposes. One instance is bound to one named session, so nothing else +/// in the process (another plugin, a library that also uses the default session) can reach or replace them. +/// +public sealed class Operations +{ + private readonly Dictionary _notes = new(); + private readonly int? _ownerThread; + + /// The session this instance is bound to. + public string Session { get; } + + /// Raised after notes/set; a stream host turns it into an outbound notification. + public event Action NoteChanged; + + /// A session name of your own: not the default session, which is shared process-wide. + /// When set, the state may only be touched on that thread (a UI or editor thread). + public Operations(string session, int? ownerThread = null) + { + Session = session; + _ownerThread = ownerThread; + ServiceBinder.BindService(session, this); + } + + /// Unbinds everything: the session is gone, later requests for it answer -32601. + public void Dispose() => Handler.DestroySession(Session); + + [JsonRpcMethod("add")] + public double Add(double l, double r) => l + r; + + [JsonRpcMethod("notes/set")] + public int Set(string key, string text) + { + Guard(); + _notes[key] = text; + NoteChanged?.Invoke(key); + return _notes.Count; + } + + [JsonRpcMethod("notes/get")] + public string Get(string key) + { + Guard(); + // An application error: a code outside -32768..-32000, the range JSON-RPC 2.0 reserves, with authored data. + return _notes.TryGetValue(key, out var text) ? text : throw new JsonRpcException(1001, "Note not found", new NoteError { Key = key }); + } + + /// + /// The cancellation token sits between the JSON parameters: C# wants optional parameters last, and the token + /// is never bound from JSON, so ["wait", 50] and {"milliseconds":50,"result":9} both work. + /// + [JsonRpcMethod("wait")] + public async Task Wait(int milliseconds, [JsonRpcCancellation] CancellationToken cancellationToken, int result = 7) + { + await Task.Delay(milliseconds, cancellationToken); + return result; + } + + /// The managed thread the method body runs on: how the check mode proves the UI-thread host works. + [JsonRpcMethod("thread")] + public int CurrentThread() => Environment.CurrentManagedThreadId; + + private void Guard() + { + if (_ownerThread is int owner && owner != Environment.CurrentManagedThreadId) + throw new InvalidOperationException("The notes belong to thread " + owner + "; this call runs on " + Environment.CurrentManagedThreadId + "."); + } + + public sealed class NoteError + { + public string Key { get; set; } + } +} diff --git a/samples/EmbeddedHost/Program.cs b/samples/EmbeddedHost/Program.cs new file mode 100644 index 0000000..73ac762 --- /dev/null +++ b/samples/EmbeddedHost/Program.cs @@ -0,0 +1,156 @@ +using System.IO.Pipes; +using System.Net; +using System.Net.Sockets; +using System.Text; +using EmbeddedHost; + +// Modes: +// http [prefix] HttpListener at http://127.0.0.1:5078/rpc/ (or the prefix given) +// pipe [name] named pipe "jsonrpc-embedded" (or the name given), newline-delimited documents +// ui [name] the pipe host, every document run on a thread that owns the state +// check all three in process, driven by a client; exit code 0 when every check passes +string mode = args.Length > 0 ? args[0] : "check"; +using var stop = new CancellationTokenSource(); +Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Cancel(); }; + +switch (mode) +{ + case "http": + { + var operations = new Operations("embedded-http"); + string prefix = args.Length > 1 ? args[1] : "http://127.0.0.1:5078/rpc/"; + Console.WriteLine("POST " + prefix + " (Ctrl+C stops)"); + await HttpListenerHost.RunAsync(operations.Session, prefix, stop.Token); + return 0; + } + case "pipe": + { + var operations = new Operations("embedded-pipe"); + string name = args.Length > 1 ? args[1] : "jsonrpc-embedded"; + Console.WriteLine("named pipe " + name + " (Ctrl+C stops)"); + await StreamHost.RunPipeAsync(name, StreamHost.DirectDispatcher(operations.Session), operations, stop.Token); + return 0; + } + case "ui": + { + using var ui = new UiThread(); + var operations = new Operations("embedded-ui", ui.ManagedThreadId); + string name = args.Length > 1 ? args[1] : "jsonrpc-embedded"; + Console.WriteLine("named pipe " + name + ", methods run on thread " + ui.ManagedThreadId + " (Ctrl+C stops)"); + await StreamHost.RunPipeAsync(name, ui.Dispatcher(operations.Session), operations, stop.Token); + return 0; + } + case "check": + return await Check.RunAsync(stop.Token) ? 0 : 1; + default: + Console.Error.WriteLine("usage: EmbeddedHost http [prefix] | pipe [name] | ui [name] | check"); + return 2; +} + +static class Check +{ + private static int _failures; + + public static async Task RunAsync(CancellationToken stop) + { + using var hosts = CancellationTokenSource.CreateLinkedTokenSource(stop); + await CheckHttpAsync(hosts.Token); + await CheckPipeAsync(hosts.Token); + await CheckUiAsync(hosts.Token); + hosts.Cancel(); + Console.WriteLine(_failures == 0 ? "all checks passed" : _failures + " check(s) failed"); + return _failures == 0; + } + + private static async Task CheckHttpAsync(CancellationToken stop) + { + var operations = new Operations("check-http"); + string prefix = "http://127.0.0.1:" + FreePort() + "/rpc/"; + var host = HttpListenerHost.RunAsync(operations.Session, prefix, stop); + using var client = new HttpClient(); + + var ok = await client.PostAsync(prefix, Json("{\"jsonrpc\":\"2.0\",\"method\":\"add\",\"params\":[1,2],\"id\":1}"), stop); + Expect("http add", (int)ok.StatusCode == 200 && await ok.Content.ReadAsStringAsync(stop) == "{\"jsonrpc\":\"2.0\",\"result\":3.0,\"id\":1}"); + + var notification = await client.PostAsync(prefix, Json("{\"jsonrpc\":\"2.0\",\"method\":\"notes/set\",\"params\":[\"a\",\"first\"]}"), stop); + Expect("http notification is 204", (int)notification.StatusCode == 204); + + var missing = await client.PostAsync(prefix, Json("{\"jsonrpc\":\"2.0\",\"method\":\"notes/get\",\"params\":{\"key\":\"zz\"},\"id\":2}"), stop); + string body = await missing.Content.ReadAsStringAsync(stop); + Expect("http application error 1001 with data", body.Contains("\"code\":1001") && body.Contains("\"Key\":\"zz\""), body); + + var wait = await client.PostAsync(prefix, Json("{\"jsonrpc\":\"2.0\",\"method\":\"wait\",\"params\":{\"milliseconds\":10,\"result\":9},\"id\":3}"), stop); + Expect("http token mid-signature, named params", (await wait.Content.ReadAsStringAsync(stop)).Contains("\"result\":9")); + + var get = await client.GetAsync(prefix, stop); + Expect("http GET is 405", (int)get.StatusCode == 405); + operations.Dispose(); + } + + private static async Task CheckPipeAsync(CancellationToken stop) + { + var operations = new Operations("check-pipe"); + string name = "jsonrpc-check-" + Environment.ProcessId; + _ = StreamHost.RunPipeAsync(name, StreamHost.DirectDispatcher(operations.Session), operations, stop); + + using var pipe = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous); + await pipe.ConnectAsync(5000, stop); + // Pipelined: three requests and a notification in one write, before reading anything. + string batch = "{\"jsonrpc\":\"2.0\",\"method\":\"add\",\"params\":[1,2],\"id\":1}\n" + + "{\"jsonrpc\":\"2.0\",\"method\":\"notes/set\",\"params\":[\"k\",\"v\"]}\n" + + "{\"jsonrpc\":\"2.0\",\"method\":\"notes/get\",\"params\":[\"k\"],\"id\":2}" + + "{\"jsonrpc\":\"2.0\",\"method\":\"wait\",\"params\":[5],\"id\":3}\n"; + await pipe.WriteAsync(Encoding.UTF8.GetBytes(batch), stop); + await pipe.FlushAsync(stop); + var reader = new StreamReader(pipe, Encoding.UTF8); + var lines = new List(); + for (int i = 0; i < 4; i++) lines.Add(await reader.ReadLineAsync(stop)); + Expect("pipe reply 1", lines[0] == "{\"jsonrpc\":\"2.0\",\"result\":3.0,\"id\":1}", lines[0]); + Expect("pipe outbound notification after notes/set", lines[1] == "{\"jsonrpc\":\"2.0\",\"method\":\"notes/changed\",\"params\":{\"key\":\"k\"}}", lines[1]); + Expect("pipe reply 2, no separator needed between requests", lines[2] == "{\"jsonrpc\":\"2.0\",\"result\":\"v\",\"id\":2}", lines[2]); + Expect("pipe reply 3, positional params skip the token", lines[3] == "{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":3}", lines[3]); + + await pipe.WriteAsync(Encoding.UTF8.GetBytes("this is not json\n"), stop); + await pipe.FlushAsync(stop); + string parseError = await reader.ReadLineAsync(stop); + Expect("pipe garbage answers -32700 and closes", parseError != null && parseError.Contains("-32700") && await reader.ReadLineAsync(stop) == null, parseError); + operations.Dispose(); + } + + private static async Task CheckUiAsync(CancellationToken stop) + { + using var ui = new UiThread(); + var operations = new Operations("check-ui", ui.ManagedThreadId); + string name = "jsonrpc-check-ui-" + Environment.ProcessId; + _ = StreamHost.RunPipeAsync(name, ui.Dispatcher(operations.Session), operations, stop); + + using var pipe = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous); + await pipe.ConnectAsync(5000, stop); + var reader = new StreamReader(pipe, Encoding.UTF8); + await pipe.WriteAsync(Encoding.UTF8.GetBytes("{\"jsonrpc\":\"2.0\",\"method\":\"thread\",\"id\":1}\n{\"jsonrpc\":\"2.0\",\"method\":\"notes/set\",\"params\":[\"a\",\"b\"],\"id\":2}\n"), stop); + await pipe.FlushAsync(stop); + string thread = await reader.ReadLineAsync(stop); + Expect("ui method body runs on the owning thread", thread == "{\"jsonrpc\":\"2.0\",\"result\":" + ui.ManagedThreadId + ",\"id\":1}", thread); + string changed = await reader.ReadLineAsync(stop); + string set = await reader.ReadLineAsync(stop); + Expect("ui state guarded by thread is reachable through the queue", changed.Contains("notes/changed") && set == "{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":2}", set); + operations.Dispose(); + } + + private static StringContent Json(string document) => new(document, Encoding.UTF8, "application/json"); + + private static int FreePort() + { + var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + int port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } + + private static void Expect(string check, bool condition, string detail = null) + { + if (!condition) _failures++; + Console.WriteLine((condition ? "ok " : "FAIL ") + check + (condition || detail == null ? "" : ": " + detail)); + } +} diff --git a/samples/EmbeddedHost/README.md b/samples/EmbeddedHost/README.md new file mode 100644 index 0000000..a2320f6 --- /dev/null +++ b/samples/EmbeddedHost/README.md @@ -0,0 +1,27 @@ +# EmbeddedHost + +The core hosted in a process that is not an ASP.NET Core application: a plugin or add-in, a desktop or editor +process, a daemon. Three hosts over the same four methods, each about a page: + +| File | Host | What it has to do itself | +| --- | --- | --- | +| `HttpListenerHost.cs` | HTTP through `System.Net.HttpListener`, which is in the base library | body limit, deadline (no client-abort token), 204 for a notification, status codes | +| `StreamHost.cs` | a named pipe (any `Stream`), newline-delimited documents both ways | framing with `JsonFramer`, a document limit, a read loop and a separate write loop, the reply separator, outbound notifications | +| `UiThread.cs` | a thread that owns the state, as an editor loop or UI thread does | a `SynchronizationContext` that runs every document, and every continuation inside a method, on that thread | + +`Operations.cs` binds the methods to a session of its own rather than the default session, which any other +component in the process shares. `notes/get` answers an application error with a code outside the range JSON-RPC +2.0 reserves; `wait` takes its cancellation token between the JSON parameters. + +``` +dotnet run -- check # all three hosts in process, driven by a client; exit code 0 when every check passes +dotnet run -- http # POST http://127.0.0.1:5078/rpc/ +dotnet run -- pipe # named pipe jsonrpc-embedded +dotnet run -- ui # the pipe host, methods run on the owning thread +``` + +``` +curl -s -X POST http://127.0.0.1:5078/rpc/ -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","method":"add","params":[1,2],"id":1}' +``` + +The README's [Hosting](../../README.md#hosting) section walks through the three files. diff --git a/samples/EmbeddedHost/StreamHost.cs b/samples/EmbeddedHost/StreamHost.cs new file mode 100644 index 0000000..045abfb --- /dev/null +++ b/samples/EmbeddedHost/StreamHost.cs @@ -0,0 +1,147 @@ +using System.Buffers; +using System.IO.Pipes; +using System.Text; +using System.Threading.Channels; +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.Serialization; + +namespace EmbeddedHost; + +/// +/// A JSON-RPC connection over any (here a named pipe) without Kestrel. Documents are +/// newline-delimited in both directions, which is what MCP's stdio transport and most line-oriented clients +/// expect. Two loops per connection: the read loop frames and processes documents in order, the write loop +/// drains a queue of replies and notifications. One loop that reads, processes and writes in turn deadlocks +/// against a client that pipelines requests, because both ends then wait for the other to read. +/// +public static class StreamHost +{ + private const int MaxDocumentBytes = 4 * 1024 * 1024; + private static readonly byte[] NewLine = { (byte)'\n' }; + + /// Runs one document: the default processes it on the calling thread; a UI host dispatches it elsewhere. + public delegate Task> Dispatcher(ReadOnlyMemory document, object context, CancellationToken cancellationToken); + + public static Dispatcher DirectDispatcher(string session) => async (document, context, cancellationToken) => + { + var output = new ArrayBufferWriter(); + await JsonRpcProcessor.ProcessAsync(session, document, output, context: context, cancellationToken: cancellationToken); + return output.WrittenMemory; // nothing for a notification + }; + + /// Accepts clients on a named pipe and serves each on its own pair of loops. + public static async Task RunPipeAsync(string pipeName, Dispatcher dispatch, Operations operations, CancellationToken stop) + { + while (!stop.IsCancellationRequested) + { + var pipe = new NamedPipeServerStream(pipeName, PipeDirection.InOut, NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, PipeOptions.Asynchronous); + try { await pipe.WaitForConnectionAsync(stop); } + catch (OperationCanceledException) { pipe.Dispose(); break; } + _ = ServeConnectionAsync(pipe, dispatch, operations, stop); + } + } + + /// Serves one connection until the peer closes it, fires, or the peer sends something that is not JSON. + public static async Task ServeConnectionAsync(Stream stream, Dispatcher dispatch, Operations operations, CancellationToken stop) + { + using (stream) + { + var outgoing = Channel.CreateUnbounded>(new UnboundedChannelOptions { SingleReader = true }); + // An outbound notification: a server-side event becomes a document on the same queue as the replies, so + // it never interleaves with a reply mid-write. Order relative to a reply is whatever the queue says. + void OnNoteChanged(string key) => outgoing.Writer.TryWrite(Notification("notes/changed", "{\"key\":" + Quote(key) + "}")); + if (operations != null) operations.NoteChanged += OnNoteChanged; + var writing = WriteLoopAsync(stream, outgoing.Reader, stop); + try + { + await ReadLoopAsync(stream, dispatch, outgoing.Writer, stop); + } + catch (OperationCanceledException) { } + catch (IOException) { } + finally + { + if (operations != null) operations.NoteChanged -= OnNoteChanged; + outgoing.Writer.TryComplete(); + try { await writing; } catch (Exception) { } + } + } + } + + private static async Task ReadLoopAsync(Stream stream, Dispatcher dispatch, ChannelWriter> outgoing, CancellationToken stop) + { + var buffer = new byte[16 * 1024]; + int filled = 0; + while (true) + { + if (filled == buffer.Length) + { + // Nothing outside Kestrel bounds this buffer: a client that never finishes a document would grow it forever. + if (buffer.Length >= MaxDocumentBytes) + { + outgoing.TryWrite(Error(-32600, "Invalid Request", "{\"limit\":\"maxDocumentBytes\",\"maximum\":" + MaxDocumentBytes + "}")); + return; + } + Array.Resize(ref buffer, Math.Min(buffer.Length * 2, MaxDocumentBytes)); + } + int read = await stream.ReadAsync(buffer.AsMemory(filled, buffer.Length - filled), stop); + if (read == 0) return; + filled += read; + + int start = 0; + while (start < filled) + { + int end = JsonFramer.FindDocumentEnd(buffer.AsSpan(start, filled - start)); + if (end < 0) + { + // -1 means either "not complete yet" or "does not start with { or [". Tell them apart here, + // or a line of garbage stalls the connection while the buffer grows to the limit. + int first = FirstNonWhitespace(buffer, start, filled); + if (first < 0) { start = filled; break; } // only whitespace so far: drop it + if (buffer[first] != (byte)'{' && buffer[first] != (byte)'[') + { + outgoing.TryWrite(Error(-32700, "Parse error", null)); // then close: the stream is out of sync + return; + } + break; // incomplete: read more + } + + // Documents on one connection run one at a time, in order. The document memory belongs to the call + // until the await returns, and only then is the buffer compacted. + var reply = await dispatch(buffer.AsMemory(start, end), stream, stop); + start += end; + if (reply.Length > 0) await outgoing.WriteAsync(reply, stop); + } + Buffer.BlockCopy(buffer, start, buffer, 0, filled - start); + filled -= start; + } + } + + private static async Task WriteLoopAsync(Stream stream, ChannelReader> outgoing, CancellationToken stop) + { + await foreach (var frame in outgoing.ReadAllAsync(stop)) + { + await stream.WriteAsync(frame, stop); + await stream.WriteAsync(NewLine, stop); // the reply separator: the core writes none + await stream.FlushAsync(stop); + } + } + + private static int FirstNonWhitespace(byte[] buffer, int start, int end) + { + for (int i = start; i < end; i++) + { + byte b = buffer[i]; + if (b != (byte)' ' && b != (byte)'\t' && b != (byte)'\r' && b != (byte)'\n') return i; + } + return -1; + } + + private static ReadOnlyMemory Error(int code, string message, string dataJson) + => Encoding.UTF8.GetBytes("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":" + code + ",\"message\":" + Quote(message) + ",\"data\":" + (dataJson ?? "null") + "},\"id\":null}"); + + private static ReadOnlyMemory Notification(string method, string paramsJson) + => Encoding.UTF8.GetBytes("{\"jsonrpc\":\"2.0\",\"method\":" + Quote(method) + ",\"params\":" + paramsJson + "}"); + + private static string Quote(string text) => "\"" + text.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; +} diff --git a/samples/EmbeddedHost/UiThread.cs b/samples/EmbeddedHost/UiThread.cs new file mode 100644 index 0000000..498eb1e --- /dev/null +++ b/samples/EmbeddedHost/UiThread.cs @@ -0,0 +1,57 @@ +using System.Collections.Concurrent; +using AustinHarris.JsonRpc; + +namespace EmbeddedHost; + +/// +/// A thread that owns the state: an editor loop, a desktop UI thread, a game's main thread. Requests arrive on +/// transport threads and are handed to this one; the method body, and every continuation after an await inside +/// it, runs here, because the thread installs itself as the . The core has no +/// dispatch context of its own (that is the 2.6 duplex work), so the host marshals whole documents. +/// +public sealed class UiThread : SynchronizationContext, IDisposable +{ + private readonly BlockingCollection<(SendOrPostCallback callback, object state)> _queue = new(); + private readonly Thread _thread; + + public int ManagedThreadId => _thread.ManagedThreadId; + + public UiThread() + { + _thread = new Thread(Loop) { Name = "ui", IsBackground = true }; + _thread.Start(); + } + + private void Loop() + { + SetSynchronizationContext(this); + foreach (var (callback, state) in _queue.GetConsumingEnumerable()) callback(state); + } + + public override void Post(SendOrPostCallback d, object state) => _queue.Add((d, state)); + + public override void Send(SendOrPostCallback d, object state) => throw new NotSupportedException("Post only."); + + /// Runs on the thread and completes when the whole async chain has. + public Task RunAsync(Func> work) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Post(async _ => + { + try { completion.SetResult(await work()); } + catch (Exception ex) { completion.SetException(ex); } + }, null); + return completion.Task; + } + + /// A that processes every document on this thread. + public StreamHost.Dispatcher Dispatcher(string session) => (document, context, cancellationToken) => + RunAsync(async () => + { + var output = new System.Buffers.ArrayBufferWriter(); + await JsonRpcProcessor.ProcessAsync(session, document, output, context: context, cancellationToken: cancellationToken); + return (ReadOnlyMemory)output.WrittenMemory; + }); + + public void Dispose() => _queue.CompleteAdding(); +} diff --git a/site/README.md b/site/README.md index cdb1b07..255a5a3 100644 --- a/site/README.md +++ b/site/README.md @@ -10,6 +10,7 @@ The pages at https://astn.github.io/JSON-RPC.NET/ are built from the Markdown fi | Json.NET serializer | `AustinHarris.JsonRpc.Newtonsoft/README.md` | | System.Text.Json serializer | `AustinHarris.JsonRpc.SystemTextJson/README.md` | | WebAssembly sample | `samples/WasmHost/README.md` | +| Embedded host sample | `samples/EmbeddedHost/README.md` | | Micro-benchmarks | `benchmarks/Micro/README.md` | | Benchmark explorer | `benchmarks/charts/explorer.html`, copied as is | diff --git a/site/build.py b/site/build.py index 5aaa0ce..8b886fe 100644 --- a/site/build.py +++ b/site/build.py @@ -61,6 +61,8 @@ "The System.Text.Json serializer package: options, source generation and UTF-8 readers on the request bytes."), ("wasm.html", "samples/WasmHost/README.md", "WebAssembly sample", "WebAssembly sample", "More", "The server running inside the browser as Blazor WebAssembly, with a benchmark against plain interop."), + ("embedded.html", "samples/EmbeddedHost/README.md", "Embedded host sample", "Embedded host sample", "More", + "The server hosted without ASP.NET Core: HttpListener, a named pipe with newline-delimited documents, and a thread that owns the state."), ("micro.html", "benchmarks/Micro/README.md", "Micro-benchmarks", "Micro-benchmarks", "More", "BenchmarkDotNet timings of one request per shape, with allocation columns."), ]